合并 master 的预览部署控制面
Project CI / Repository checks (pull_request) Successful in 55s
Project CI / Frontend tests (pull_request) Successful in 2m56s
Project CI / Backend tests (pull_request) Successful in 3m35s
Project CI / Native shell tests (pull_request) Successful in 17m44s

保留 Spine 序列帧与 Jenkins 容器预览决策记录

合入 preview-deployer-web、preview-deployer-server 及部署脚本
This commit is contained in:
2026-08-15 17:48:16 +08:00
39 changed files with 5433 additions and 2 deletions
+2
View File
@@ -39,6 +39,8 @@ temp*build*/
/apps/ai-game-creator-shell/game-creator.config.local.json
/apps/mobile-shell/.expo/
/apps/mobile-shell/.expo-export-smoke/
/apps/preview-deployer-web/dist/
/apps/preview-deployer-web/node_modules/
/server-rs/.spacetimedb/
/server-rs/.data/
/public/generated-animations
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light" />
<title>Docker 预览发布</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@genarrative/preview-deployer-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"typecheck": "tsc --noEmit -p tsconfig.json",
"test": "vitest run -c vitest.config.ts",
"build": "npm run typecheck && vite build",
"preview": "vite preview --host 127.0.0.1"
},
"dependencies": {
"@vitejs/plugin-react": "^5.0.4",
"lucide-react": "^0.546.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"vite": "^6.2.0"
},
"devDependencies": {
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"typescript": "~5.8.2",
"vitest": "^0.34.6"
}
}
@@ -0,0 +1,108 @@
/* @vitest-environment jsdom */
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import * as api from './api';
import { PreviewDeployerApp } from './PreviewDeployerApp';
vi.mock('./api');
afterEach(() => {
cleanup();
});
beforeEach(() => {
vi.mocked(api.getSession).mockResolvedValue({ authenticated: true });
vi.mocked(api.createSession).mockResolvedValue({ authenticated: true });
vi.mocked(api.deleteSession).mockResolvedValue(undefined);
vi.mocked(api.listDeployments).mockResolvedValue([]);
vi.mocked(api.createDeployment).mockResolvedValue({
id: 'preview-1',
branch: 'master',
status: 'queued',
health: 'pending',
createdAt: '2026-08-15T00:00:00Z',
updatedAt: '2026-08-15T00:00:00Z',
});
vi.mocked(api.uninstallDeployment).mockResolvedValue({
id: 'preview-1',
branch: 'master',
status: 'uninstalling',
health: 'pending',
createdAt: '2026-08-15T00:00:00Z',
updatedAt: '2026-08-15T00:02:00Z',
});
});
test('requires an access token before showing deployments', async () => {
const user = userEvent.setup();
vi.mocked(api.getSession).mockResolvedValue({ authenticated: false });
render(<PreviewDeployerApp />);
expect(await screen.findByText('访问口令')).toBeTruthy();
await user.type(screen.getByPlaceholderText('请输入访问口令'), 'team-token');
await user.click(screen.getByRole('button', { name: '进入发布面板' }));
await waitFor(() => {
expect(api.createSession).toHaveBeenCalledWith('team-token');
});
expect(await screen.findByText('构建并发布一个分支')).toBeTruthy();
});
test('submits a branch with an optional commit hash', async () => {
const user = userEvent.setup();
render(<PreviewDeployerApp />);
const branchInput = await screen.findByPlaceholderText('master');
await user.clear(branchInput);
await user.type(branchInput, 'feature/preview');
await user.type(
screen.getByPlaceholderText('留空则构建分支最新提交'),
'aa5221abc',
);
await user.click(screen.getByRole('button', { name: '开始构建' }));
await waitFor(() => {
expect(api.createDeployment).toHaveBeenCalledWith({
branch: 'feature/preview',
commitHash: 'aa5221abc',
});
});
});
test('shows health and web url, then confirms uninstall', async () => {
vi.mocked(api.listDeployments).mockResolvedValue([
{
id: 'preview-2',
branch: 'feature/demo',
resolvedCommit: '1234567890abcdef',
status: 'running',
health: 'healthy',
webUrl: 'http://192.168.35.82:8400',
createdAt: 1_787_270_400,
updatedAt: 1_787_270_460,
},
]);
render(<PreviewDeployerApp />);
expect(await screen.findByText('健康')).toBeTruthy();
expect(
screen.getByRole('link', { name: / Web/u }).getAttribute('href'),
).toBe('http://192.168.35.82:8400');
fireEvent.click(screen.getByRole('button', { name: '卸载' }));
expect(screen.getByRole('dialog')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '确认卸载' }));
await waitFor(() => {
expect(api.uninstallDeployment).toHaveBeenCalledWith('preview-2');
});
});
File diff suppressed because it is too large Load Diff
+114
View File
@@ -0,0 +1,114 @@
import { afterEach, expect, test, vi } from 'vitest';
import {
createDeployment,
createSession,
deleteSession,
getSession,
listDeployments,
uninstallDeployment,
} from './api';
afterEach(() => {
vi.unstubAllGlobals();
});
test('lists deployments from the same-origin control plane', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
deployments: [
{
id: 'preview-1',
branch: 'feature/demo',
status: 'running',
health: 'healthy',
createdAt: '2026-08-15T00:00:00Z',
updatedAt: '2026-08-15T00:01:00Z',
},
],
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
);
vi.stubGlobal('fetch', fetchMock);
const deployments = await listDeployments();
expect(deployments).toHaveLength(1);
expect(fetchMock).toHaveBeenCalledWith(
'/api/preview-deployer/deployments',
expect.objectContaining({ credentials: 'same-origin' }),
);
});
test('uses an http-only session without browser token persistence', async () => {
const fetchMock = vi
.fn()
.mockImplementation((_url: string, init: RequestInit) =>
Promise.resolve(
new Response(
init.method === 'DELETE'
? null
: JSON.stringify({ authenticated: true }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
),
);
vi.stubGlobal('fetch', fetchMock);
expect(await getSession()).toEqual({ authenticated: true });
await createSession('team-access-token');
await deleteSession();
expect(fetchMock).toHaveBeenNthCalledWith(
2,
'/api/preview-deployer/session',
expect.objectContaining({
method: 'POST',
credentials: 'same-origin',
body: JSON.stringify({ accessToken: 'team-access-token' }),
}),
);
expect(fetchMock).toHaveBeenNthCalledWith(
3,
'/api/preview-deployer/session',
expect.objectContaining({ method: 'DELETE' }),
);
});
test('submits only branch and optional commit to fixed endpoints', async () => {
const buildResponse = () =>
new Response(
JSON.stringify({
id: 'preview-2',
branch: 'master',
status: 'queued',
health: 'pending',
createdAt: '2026-08-15T00:00:00Z',
updatedAt: '2026-08-15T00:00:00Z',
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
const fetchMock = vi
.fn()
.mockImplementation(() => Promise.resolve(buildResponse()));
vi.stubGlobal('fetch', fetchMock);
await createDeployment({ branch: 'master', commitHash: 'aa5221abc' });
await uninstallDeployment('preview/2');
expect(fetchMock).toHaveBeenNthCalledWith(
1,
'/api/preview-deployer/deployments',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ branch: 'master', commitHash: 'aa5221abc' }),
}),
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
'/api/preview-deployer/deployments/preview%2F2/uninstall',
expect.objectContaining({ method: 'POST' }),
);
});
+128
View File
@@ -0,0 +1,128 @@
import type { CreateDeploymentInput, PreviewDeployment } from './types';
const API_BASE = '/api/preview-deployer';
export class PreviewDeployerApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = 'PreviewDeployerApiError';
this.status = status;
}
}
export interface PreviewDeployerSession {
authenticated: boolean;
}
export async function getSession(signal?: AbortSignal) {
const session = await request<PreviewDeployerSession | null>('/session', {
signal,
});
return session ?? { authenticated: true };
}
export function createSession(accessToken: string) {
return request<PreviewDeployerSession>('/session', {
method: 'POST',
body: { accessToken },
});
}
export function deleteSession() {
return request<void>('/session', { method: 'DELETE' });
}
export async function listDeployments(signal?: AbortSignal) {
const payload = await request<
PreviewDeployment[] | { deployments: PreviewDeployment[] }
>('/deployments', { signal });
return Array.isArray(payload) ? payload : payload.deployments;
}
export function createDeployment(input: CreateDeploymentInput) {
return request<PreviewDeployment>('/deployments', {
method: 'POST',
body: input,
});
}
export function uninstallDeployment(deploymentId: string) {
return request<PreviewDeployment>(
`/deployments/${encodeURIComponent(deploymentId)}/uninstall`,
{ method: 'POST', body: {} },
);
}
interface RequestOptions {
method?: string;
body?: unknown;
signal?: AbortSignal;
}
async function request<T>(
path: string,
options: RequestOptions = {},
): Promise<T> {
const headers: Record<string, string> = { Accept: 'application/json' };
const init: RequestInit = {
method: options.method ?? 'GET',
headers,
signal: options.signal,
credentials: 'same-origin',
};
if (options.body !== undefined) {
headers['Content-Type'] = 'application/json';
init.body = JSON.stringify(options.body);
}
const response = await fetch(`${API_BASE}${path}`, init);
const responseText = await response.text();
const payload = parseJson(responseText);
if (!response.ok) {
throw new PreviewDeployerApiError(
readErrorMessage(payload) || `请求失败(HTTP ${response.status}`,
response.status,
);
}
return unwrapPayload<T>(payload);
}
function parseJson(value: string): unknown {
if (!value.trim()) {
return null;
}
try {
return JSON.parse(value) as unknown;
} catch {
return value;
}
}
function unwrapPayload<T>(payload: unknown): T {
if (isRecord(payload) && 'data' in payload) {
return payload.data as T;
}
return payload as T;
}
function readErrorMessage(payload: unknown) {
if (typeof payload === 'string') {
return payload.trim();
}
if (!isRecord(payload)) {
return '';
}
if (typeof payload.message === 'string') {
return payload.message;
}
if (isRecord(payload.error) && typeof payload.error.message === 'string') {
return payload.error.message;
}
return '';
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
+17
View File
@@ -0,0 +1,17 @@
import './styles.css';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { PreviewDeployerApp } from './PreviewDeployerApp';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error('Missing #root container');
}
createRoot(rootElement).render(
<StrictMode>
<PreviewDeployerApp />
</StrictMode>,
);
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
export type DeploymentStatus =
| 'queued'
| 'building'
| 'deploying'
| 'running'
| 'uninstalling'
| 'stopped'
| 'failed'
| 'cancelled';
export type DeploymentHealth = 'pending' | 'healthy' | 'unhealthy' | 'unknown';
export interface PreviewDeployment {
id: string;
branch: string;
commitHash?: string | null;
resolvedCommit?: string | null;
status: DeploymentStatus;
health: DeploymentHealth;
webUrl?: string | null;
jenkinsBuildUrl?: string | null;
createdAt: string | number;
updatedAt: string | number;
message?: string | null;
canUninstall?: boolean;
}
export interface CreateDeploymentInput {
branch: string;
commitHash?: string;
}
@@ -0,0 +1,15 @@
import { expect, test } from 'vitest';
import { validateBranch, validateCommitHash } from './validation';
test('accepts normal branch names and rejects unsafe values', () => {
expect(validateBranch('feature/preview-panel')).toBe('');
expect(validateBranch('../master')).toBe('分支名格式不正确');
expect(validateBranch('feature;rm')).toBe('分支名格式不正确');
});
test('commit hash is optional but must be a git hash when present', () => {
expect(validateCommitHash('')).toBe('');
expect(validateCommitHash('a0242e35')).toBe('');
expect(validateCommitHash('not-a-hash')).not.toBe('');
});
@@ -0,0 +1,27 @@
const BRANCH_PATTERN = /^[0-9A-Za-z._/-]+$/u;
const COMMIT_PATTERN = /^[0-9a-fA-F]{7,40}$/u;
export function validateBranch(value: string) {
const branch = value.trim();
if (!branch) {
return '请输入分支名';
}
if (
branch.length > 200 ||
!BRANCH_PATTERN.test(branch) ||
branch.startsWith('/') ||
branch.endsWith('/') ||
branch.includes('..')
) {
return '分支名格式不正确';
}
return '';
}
export function validateCommitHash(value: string) {
const commitHash = value.trim();
if (commitHash && !COMMIT_PATTERN.test(commitHash)) {
return 'Commit Hash 需为 7 到 40 位十六进制字符';
}
return '';
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": false,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": false,
"strict": true,
"noUncheckedIndexedAccess": true,
"jsx": "react-jsx",
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}
+36
View File
@@ -0,0 +1,36 @@
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import react from '@vitejs/plugin-react';
import { defineConfig, loadEnv } from 'vite';
const appRoot = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(appRoot, '../..');
export default defineConfig(({ mode }) => {
const env = {
...loadEnv(mode, repoRoot, ''),
...loadEnv(mode, appRoot, ''),
};
const apiTarget = env.PREVIEW_DEPLOYER_API_TARGET ?? 'http://127.0.0.1:8410';
return {
root: appRoot,
envDir: repoRoot,
base: env.PREVIEW_DEPLOYER_WEB_BASE ?? '/build/',
plugins: [react()],
server: {
proxy: {
'/api/preview-deployer': {
target: apiTarget,
changeOrigin: true,
secure: false,
},
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
},
};
});
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
},
});
+1 -1
View File
@@ -118,7 +118,7 @@ services:
soft: 4096
hard: 4096
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1/healthz"]
test: ["CMD", "wget", "-qO-", "http://127.0.0.1/"]
interval: 10s
timeout: 5s
retries: 12
+13
View File
@@ -0,0 +1,13 @@
# 仅部署在本机内网 HTTP 入口 http://192.168.35.82/build/。
GENARRATIVE_PREVIEW_DEPLOYER_BIND=127.0.0.1:8410
GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_BASE_URL=http://127.0.0.1:8080/jenkins/
GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_USERNAME=preview-deployer-service
GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_API_TOKEN=<由Jenkins管理员生成的专用API Token>
GENARRATIVE_PREVIEW_DEPLOYER_ACCESS_TOKEN=<至少24字符的控制面访问口令>
GENARRATIVE_PREVIEW_DEPLOYER_ALLOWED_HOSTS=192.168.35.82
GENARRATIVE_PREVIEW_DEPLOYER_ALLOWED_ORIGINS=http://192.168.35.82
GENARRATIVE_PREVIEW_DEPLOYER_WEB_HOST=192.168.35.82
GENARRATIVE_PREVIEW_DEPLOYER_STATE_FILE=/var/lib/genarrative/preview-deployer/state.json
GENARRATIVE_PREVIEW_DEPLOYER_STATIC_DIR=/opt/genarrative/preview-deployer/web
GENARRATIVE_PREVIEW_DEPLOYER_SECURE_COOKIE=false
RUST_LOG=info,tower_http=info
@@ -0,0 +1,35 @@
location = /build {
return 301 /build/;
}
location ^~ /build/ {
proxy_pass http://127.0.0.1:8410;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_connect_timeout 10s;
proxy_send_timeout 60s;
proxy_read_timeout 90s;
}
location ^~ /api/preview-deployer/ {
proxy_pass http://127.0.0.1:8410;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_connect_timeout 10s;
proxy_send_timeout 60s;
proxy_read_timeout 90s;
}
@@ -0,0 +1,26 @@
[Unit]
Description=Genarrative Jenkins Preview Deployer
After=network-online.target jenkins.service
Wants=network-online.target
Requires=jenkins.service
[Service]
Type=simple
User=jenkins
Group=jenkins
WorkingDirectory=/opt/genarrative/preview-deployer
EnvironmentFile=/etc/genarrative/preview-deployer.env
ExecStart=/opt/genarrative/preview-deployer/preview-deployer-server
Restart=on-failure
RestartSec=5
KillSignal=SIGINT
TimeoutStopSec=30
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/genarrative/preview-deployer
[Install]
WantedBy=multi-user.target
+1
View File
@@ -46,6 +46,7 @@
### 后台、宿主壳与运维
- [Dashboard 运营看板方案](./technical/【后台管理】Dashboard运营看板方案-2026-06-23.md)
- [Jenkins 容器预览部署控制面](./technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md)
- [后台多账号与 Tab 访问权限](./technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md)
- [宿主壳能力统一协议](./【前端架构】宿主壳能力统一协议-2026-06-17.md)
- [Expo React Native 与 Tauri 宿主壳方案](./【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md)
@@ -14170,3 +14170,12 @@
- merge master 后保留角色图层浮动工具栏和右键菜单中的 `生成动画` 快捷入口,统一进入同一个角色动作 dialog;`character-animation` 结果不支持快速编辑,仍保留 `改造 / 去背景 / 拆帧 / 下载`。快速编辑入口与提交门禁使用统一正向白名单,未知媒体或素材类型默认拒绝。
- Spine 序列帧工具栏的“去背景”暂定为纯算法绿幕扣除:只读取正式序列帧 objectKey,使用本地 `screen-color-keying` 处理约定的 `#00FF00` 绿幕,不调用 BgFilter、阿里云或其它模型;普通图片 `/api/editor/images/background-removals` 的通用去背景链路不受影响。
- `/api/external/v1` 不开放上述内部字段或直接转换路由,不修改 OpenAPI;本次不改 SpacetimeDB 表结构、迁移或生成 bindings。
## 2026-08-15 Jenkins 容器预览部署使用独立控制面
- 决策:多人内网容器预览不把操作表单塞进 Jenkins 页面,也不让 SPA 直接操作 Docker。独立 `preview-deployer` SPA 通过同源 Axum 代理触发固定 `shared/Genarrative-Preview-Deployer` Job;浏览器只持有控制面 HttpOnly 会话,Jenkins service account 和 API Token 只存在服务端环境。
- 部署入口只使用内网 `http://192.168.35.82/build/`,不配置公网域名;预览 Web 端口固定为 `8400..8499`,卸载后立即释放租约,运行状态由页面刷新时的实时 Web 探针更新。
- Jenkins 的 Compose 编排、Dockerfile 入口和执行脚本固定取自受保护的 master 控制器 checkout,目标分支只作为应用源码构建上下文;控制 Job 只授予受信任开发者和专用服务账号。
- 实例与端口:分支规范化后形成稳定 `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`
@@ -0,0 +1,106 @@
# Jenkins 容器预览部署控制面技术方案
日期:`2026-08-15`
## 目标
为内网同事提供独立 SPA,用于填写源码分支和可选 commit,触发 Jenkins 构建并发布完整 Docker Compose 预览栈。页面持续刷新排队、构建、发布和卸载状态,成功后展示容器健康状态与 Web 内网地址。
控制面不替代 Jenkins:Jenkins 继续负责源码检出、全量镜像构建、SpacetimeDB 模块发布、容器启动、健康验证、卸载和构建日志。浏览器不得持有 Jenkins 用户名、API Token、Crumb 或 Docker 权限。
## 架构
```text
同事浏览器
-> preview-deployer SPA
-> preview-deployer-server 同源 API
-> 固定 Jenkins Job shared/Genarrative-Preview-Deployer
-> 独立 Compose project + 端口租约 + preview-result.json
```
- SPA 只通过内网 `http://192.168.35.82/build/` 提供页面,API 使用同源 `/api/preview-deployer/`;不配置公网域名,不复用后台管理员 Token 或业务 API 登录态。
- `preview-deployer-server` 只代理固定 Jenkins Job,Jenkins 凭据只从服务端环境变量读取,不进入前端 bundle、JSON 响应或日志。
- Jenkins Job 使用仓库现有 `deploy/container/` 全量容器资产,并通过独立 Compose project 支持多个分支实例并存;为保护端口和共享 Docker 状态,构建动作在首版中串行排队。
- 页面只读取控制面归一化后的状态,不解析 Jenkins console,也不直连 Jenkins API。
## 部署身份与端口
`deploymentId` 由规范化分支名与分支名摘要确定,同一分支稳定得到同一 ID;commit 不进入 ID,因此同一分支重新构建或指定不同 commit 时复用同一预览实例和 Web 端口。
Web 端口池固定为 `8400..8499`
1. Jenkins 在全局文件锁内读取受控状态目录。
2. 已登记分支复用原端口;新分支选择状态表、宿主监听和 Docker 映射均未占用的端口。
3. Jenkins 为实例使用独立 Compose project,例如 `genarrative-preview-a81f39c2d43e76ab`
4. 端口租约必须在容器完成真实绑定前持续受锁保护;失败构建不得把端口分配给第二个实例。
5. 卸载成功后删除该实例的 Compose 栈、受控状态和端口租约。
同一分支重新构建时先完成新镜像构建,再停止旧 Compose 栈并启动新版本;源码或镜像构建失败不会提前删除原有健康实例。容器切换后的失败仍会在页面明确显示,不伪装成旧版本继续运行。
页面每次刷新运行中实例时,控制服务直接探测受控 Web URL;后续容器异常会将健康状态更新为 `unhealthy`,不会永久沿用 Jenkins 部署完成时的快照。
目标分支只提供应用构建上下文。Compose 编排文件、Dockerfile 入口和 Jenkins 执行脚本固定取自受保护的 `master` 控制器 checkout,避免普通分支替换编排文件挂载宿主路径或启用特权容器。该 Job 仍只应开放给受信任开发者,并建议在隔离构建节点运行。
SpacetimeDB 与 OTLP 不映射宿主端口;Jenkins 通过受控 Compose 网络中的 SpacetimeDB 容器地址完成模块发布,运行服务之间继续使用 Compose DNS。页面只展示 Web 内网地址 `http://<预览宿主>:<webPort>`
## Jenkins 参数与产物
固定 Job`shared/Genarrative-Preview-Deployer`
参数:
- `ACTION``DEPLOY``UNINSTALL``STATUS`
- `SOURCE_BRANCH`:部署时必填。
- `COMMIT_HASH`:部署时可选,7 到 40 位十六进制。
- `DEPLOYMENT_ID`:卸载和状态查询时必填。
分支名只允许数字、字母、点、下划线、短横线和斜杠,不允许首尾斜杠或连续点号。Jenkins 必须复用 `scripts/jenkins-checkout-source.sh`,验证 commit 真实属于目标远端分支;SPA 和代理服务的输入校验不能代替流水线校验。
每次运行归档 `preview-result.json`,至少包含:
```json
{
"schemaVersion": 1,
"deploymentId": "feature-login-a81f39c2",
"branch": "feature/login",
"requestedCommit": "",
"resolvedCommit": "0123456789abcdef",
"status": "running",
"health": "healthy",
"webPort": 8403,
"webUrl": "http://192.168.35.82:8403",
"composeProject": "genarrative-preview-feature-login-a81f39c2",
"updatedAt": "2026-08-15T08:00:00.000Z"
}
```
控制面只读取固定 artifact 路径和受限字段;不得把完整 console 内容返回浏览器。
## 控制面 API
- `POST /api/preview-deployer/session`:使用控制面访问口令建立 `HttpOnly + SameSite=Strict` 会话。
- `GET /api/preview-deployer/session`:查询当前会话状态。
- `DELETE /api/preview-deployer/session`:退出。
- `GET /api/preview-deployer/deployments`:列出由控制面触发和恢复的部署。
- `POST /api/preview-deployer/deployments`:提交 `{ branch, commitHash? }`
- `GET /api/preview-deployer/deployments/{id}`:刷新队列、构建与 artifact 状态。
- `POST /api/preview-deployer/deployments/{id}/uninstall`:触发固定 Job 的卸载动作。
页面状态统一为 `queued / building / deploying / running / uninstalling / stopped / failed / cancelled`,健康状态统一为 `pending / healthy / unhealthy / unknown`
## 安全边界
- 服务端缺少控制面访问口令或 Jenkins service account 凭据时必须拒绝启动,不允许退化成匿名写接口。
- Jenkins service account 只授予 `shared/Genarrative-Preview-Deployer``Job/Read``Job/Build` 和读取构建产物所需权限,不授 `Overall/Administer``Job/Configure``Job/Delete`
- 后端固定 Jenkins origin、Job 路径和参数白名单;客户端不能传 URL、Job 名、Compose project、容器名、宿主端口或 Jenkins 凭据。
- Jenkins POST 支持动态 CrumbAPI Token 即使免 Crumb,也不能把 Token 放进 URL 或日志。
- API 默认只接受同源请求,写请求校验 Origin;内网本身不作为认证。
- 同一 deployment 的发布和卸载串行执行;重复请求必须幂等或明确返回冲突。
- 卸载只接受受控状态中存在且 ID 完全匹配的实例,并进行二次确认;禁止执行任意 Docker、Git、shell 或 Compose project 参数。
## 验收
- 后端:输入校验、登录会话、Origin、Crumb、Jenkins `401/403/404/5xx`、queue 到 build 状态机、artifact schema、卸载所有权和幂等测试。
- 前端:登录、分支与可选 commit、自动刷新、排队/构建/成功/失败状态、内网链接、卸载确认和刷新恢复测试。
- Jenkins:两个分支依次发布后在不同端口并存;同一分支换 commit 优先复用端口;非分支 commit 被拒绝;卸载只删除目标实例并释放端口。
- 通用:`npm run check:encoding`、相关 typecheck/build/test、Rust 定向测试和 `git diff --check`
@@ -674,6 +674,8 @@ npm run container:down
容器方案默认暴露 `http://127.0.0.1:18080``api-server` 在容器内监听 `0.0.0.0:8082`Nginx 通过 `api-server:8082` upstream 反代 `/api/``/admin/api/`。SpacetimeDB 也纳入 compose,容器内由 `spacetimedb:3101` 提供服务,宿主机通过 `http://127.0.0.1:13101` 进行模块发布;Collector 镜像使用 `otel/opentelemetry-collector-contrib:0.151.0`。生产 provision 侧现在由目标 dev / release agent 自己准备 `provision-tools/otelcol-contrib`,并安装本机 `otelcol-contrib.service`,真实库名、token 和外部服务密钥只写本地 `deploy/container/api-server.env`,不提交 Git。旧 gallery K6 profile 已退役;当前容器拓扑(明确不含 BgFilter worker)、端口和 OTLP debug exporter 使用方法见 `deploy/container/README.md`
`npm run container:config` 默认只做 quiet 校验,避免把本地 env 中的 token 展开到终端;确需排查完整 compose 时再传 `-- --print`
多人内网预览入口固定为 `http://192.168.35.82/build/`,不配置公网域名。该独立 Jenkins 容器预览部署控制面不让浏览器直接操作 Docker 或持有 Jenkins TokenSPA 通过同源代理触发固定 `shared/Genarrative-Preview-Deployer` Job。每个分支使用稳定 `deploymentId` 和独立 Compose projectWeb 端口从 `8400..8499` 在文件锁内分配,同一分支换 commit 优先复用端口,卸载后释放。Jenkins 用 `preview-result.json` 向页面提供 resolved commit、发布结果和内网 Web URL,页面刷新时由控制服务实时复核 Web 健康。安装资产为 `deploy/systemd/genarrative-preview-deployer.service``deploy/env/preview-deployer.env.example``deploy/nginx/genarrative-preview-deployer-lan.conf`;完整合同见 `docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md`
隔离验证 worker 队列和 API-only 更新时使用 `npm run container:worker-smoke -- smoke`。该命令不复用 `deploy/container/api-server.env`,会在 `deploy/container/worker-smoke/` 生成本机专用 env 与端口 state,并且只使用 unsupported job 验证 worker claim / fail 回写,不覆盖 BgFilter 成功、失败或 fallback 链路,也不需要真实外部生成密钥;本机 crates.io 网络不稳时使用 `--local-binary`,由容器内 Cargo 复用本机 Cargo 缓存构建,并把产物放进 Debian bookworm smoke runtime。
独立 BgFilter worker 的本机全进程验证先运行 `cargo build -p api-server --manifest-path server-rs/Cargo.toml`,再依次运行 `npm run bgfilter-worker:smoke-test``npm run bgfilter-worker:load-smoke``npm run bgfilter-worker:fault-smoke`。三条命令只使用动态 loopback 端口、假 OSS 签名配置和本地 mock provider;不会读取仓库 `.env*` 或请求真实 BgFilter / OSS。自定义或 WSL binary 通过 `GENARRATIVE_BGFILTER_SMOKE_BINARY` 指定。当前 fault 范围包含 overload、queue deadline、两类 HTTP 状态顺序重试结果,以及 provider 成功响应 body 中途 reset 后第二次 attempt 串行成功;慢读、大响应、父侧客户端断连与 SIGTERM 排空另行验证。
+151
View File
@@ -0,0 +1,151 @@
pipeline {
agent {
label 'linux && genarrative-build'
}
options {
disableConcurrentBuilds()
skipDefaultCheckout(true)
timeout(time: 120, unit: 'MINUTES')
buildDiscarder(logRotator(numToKeepStr: '50', artifactNumToKeepStr: '50'))
}
environment {
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'
}
parameters {
choice(name: 'ACTION', choices: ['DEPLOY', 'STATUS', 'UNINSTALL'], description: '部署、查询状态或卸载分支预览容器')
string(name: 'SOURCE_BRANCH', defaultValue: '', description: '源码分支;DEPLOY 必填,同一分支稳定映射到同一个预览实例')
string(name: 'COMMIT_HASH', defaultValue: '', description: '可选;必须是 SOURCE_BRANCH 历史中的 commit')
string(name: 'DEPLOYMENT_ID', defaultValue: '', description: '可选;留空时由 SOURCE_BRANCH 稳定生成,查询和卸载可直接传部署 ID')
}
stages {
stage('Checkout Controller') {
steps {
checkout([
$class: 'GitSCM',
branches: [[name: '*/master']],
doGenerateSubmoduleConfigurations: false,
extensions: [
[$class: 'CleanBeforeCheckout'],
[$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true],
],
userRemoteConfigs: [[
url: env.GIT_REMOTE_URL,
credentialsId: env.GIT_REMOTE_CREDENTIAL_ID,
refspec: '+refs/heads/master:refs/remotes/origin/master',
]],
])
}
}
stage('Validate Request') {
steps {
sh '''
set -euo pipefail
ACTION="${ACTION:-}"
SOURCE_BRANCH="${SOURCE_BRANCH:-}"
COMMIT_HASH="${COMMIT_HASH:-}"
DEPLOYMENT_ID="${DEPLOYMENT_ID:-}"
case "${ACTION}" in DEPLOY|STATUS|UNINSTALL) ;; *) exit 1 ;; esac
if [ -n "${SOURCE_BRANCH}" ]; then
git check-ref-format --branch "${SOURCE_BRANCH}" >/dev/null
fi
if [ -n "${COMMIT_HASH}" ] && ! printf '%s' "${COMMIT_HASH}" | grep -Eq '^[0-9a-fA-F]{7,40}$'; then
echo 'COMMIT_HASH 只能填写 7 到 40 位十六进制 Git commit hash。' >&2
exit 1
fi
if [ -n "${DEPLOYMENT_ID}" ] && ! printf '%s' "${DEPLOYMENT_ID}" | grep -Eq '^preview-[0-9a-f]{16}$'; then
echo 'DEPLOYMENT_ID 格式非法。' >&2
exit 1
fi
'''
}
}
stage('Checkout Requested Source') {
when {
expression { params.ACTION == 'DEPLOY' }
}
steps {
dir('source') {
script {
def remoteConfig = [
url: env.GIT_REMOTE_URL,
credentialsId: env.GIT_REMOTE_CREDENTIAL_ID,
refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}",
]
checkout([
$class: 'GitSCM',
branches: [[name: "*/${params.SOURCE_BRANCH}"]],
doGenerateSubmoduleConfigurations: false,
extensions: [
[$class: 'CleanBeforeCheckout'],
[$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true],
],
userRemoteConfigs: [remoteConfig],
])
}
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_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"
'''
}
}
}
}
stage('Execute Preview Action') {
steps {
sh '''
set -euo pipefail
chmod +x scripts/jenkins-preview-deployer.sh
SOURCE_DIR="${WORKSPACE}/source" \
RESULT_FILE="${WORKSPACE}/preview-result.json" \
DESCRIPTION_FILE="${WORKSPACE}/.jenkins-preview-description" \
scripts/jenkins-preview-deployer.sh
'''
script {
if (fileExists('.jenkins-preview-description')) {
currentBuild.description = readFile('.jenkins-preview-description').trim()
}
}
}
}
}
post {
always {
script {
if (!fileExists('preview-result.json')) {
writeFile file: 'preview-result.json', text: groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson([
schemaVersion: 1,
action: params.ACTION ?: 'UNKNOWN',
deploymentId: params.DEPLOYMENT_ID ?: null,
projectName: params.DEPLOYMENT_ID ? "genarrative-${params.DEPLOYMENT_ID}" : null,
branch: params.SOURCE_BRANCH ?: null,
requestedCommit: params.COMMIT_HASH ?: null,
resolvedCommit: null,
status: 'failed',
health: 'unknown',
webUrl: null,
message: 'Jenkins 在生成部署结果前失败,请查看构建日志。',
updatedAt: new Date().format("yyyy-MM-dd'T'HH:mm:ssXXX"),
])) + '\n'
}
}
archiveArtifacts artifacts: 'preview-result.json', allowEmptyArchive: false, fingerprint: true
}
}
}

Some files were not shown because too many files have changed in this diff Show More