From 41874ab391807b435337b99476431971a913543b Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 15 Aug 2026 17:18:10 +0800 Subject: [PATCH 01/19] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=86=85=E7=BD=91?= =?UTF-8?q?=E5=AE=B9=E5=99=A8=E9=A2=84=E8=A7=88=E9=83=A8=E7=BD=B2=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增分支与指定提交的预览部署 SPA 和 Jenkins 代理服务 新增多实例 Docker 预览流水线、端口租约、实时健康状态和卸载能力 新增 /build 内网路由、systemd 部署资产和运维文档 修正容器 Nginx 健康检查探针 --- .gitignore | 2 + apps/preview-deployer-web/index.html | 13 + apps/preview-deployer-web/package.json | 26 + .../src/PreviewDeployerApp.test.tsx | 108 ++ .../src/PreviewDeployerApp.tsx | 665 ++++++++++ apps/preview-deployer-web/src/api.test.ts | 114 ++ apps/preview-deployer-web/src/api.ts | 128 ++ apps/preview-deployer-web/src/main.tsx | 17 + apps/preview-deployer-web/src/styles.css | 613 ++++++++++ apps/preview-deployer-web/src/types.ts | 31 + .../src/validation.test.ts | 15 + apps/preview-deployer-web/src/validation.ts | 27 + apps/preview-deployer-web/src/vite-env.d.ts | 1 + apps/preview-deployer-web/tsconfig.json | 19 + apps/preview-deployer-web/vite.config.ts | 36 + apps/preview-deployer-web/vitest.config.ts | 8 + deploy/container/docker-compose.loadtest.yml | 2 +- deploy/env/preview-deployer.env.example | 13 + .../genarrative-preview-deployer-lan.conf | 35 + .../genarrative-preview-deployer.service | 26 + docs/README.md | 1 + .../shared-memory/decision-log.md | 8 + ...Jenkins容器预览部署控制面技术方案-2026-08-15.md | 106 ++ ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 + jenkins/Jenkinsfile.preview-deployer | 147 +++ jenkins/preview-deployer-job-config.xml | 43 + package.json | 8 +- scripts/check-preview-deployer.mjs | 180 +++ scripts/deploy/preview-deployer-install.sh | 40 + scripts/jenkins-preview-deployer.sh | 478 ++++++++ scripts/preview-deployment-status.mjs | 297 +++++ server-rs/Cargo.lock | 34 + server-rs/Cargo.toml | 1 + .../crates/preview-deployer-server/Cargo.toml | 22 + .../preview-deployer-server/src/config.rs | 177 +++ .../preview-deployer-server/src/jenkins.rs | 297 +++++ .../crates/preview-deployer-server/src/lib.rs | 1078 +++++++++++++++++ .../preview-deployer-server/src/main.rs | 42 + .../preview-deployer-server/src/tests.rs | 521 ++++++++ 39 files changed, 5379 insertions(+), 2 deletions(-) create mode 100644 apps/preview-deployer-web/index.html create mode 100644 apps/preview-deployer-web/package.json create mode 100644 apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx create mode 100644 apps/preview-deployer-web/src/PreviewDeployerApp.tsx create mode 100644 apps/preview-deployer-web/src/api.test.ts create mode 100644 apps/preview-deployer-web/src/api.ts create mode 100644 apps/preview-deployer-web/src/main.tsx create mode 100644 apps/preview-deployer-web/src/styles.css create mode 100644 apps/preview-deployer-web/src/types.ts create mode 100644 apps/preview-deployer-web/src/validation.test.ts create mode 100644 apps/preview-deployer-web/src/validation.ts create mode 100644 apps/preview-deployer-web/src/vite-env.d.ts create mode 100644 apps/preview-deployer-web/tsconfig.json create mode 100644 apps/preview-deployer-web/vite.config.ts create mode 100644 apps/preview-deployer-web/vitest.config.ts create mode 100644 deploy/env/preview-deployer.env.example create mode 100644 deploy/nginx/genarrative-preview-deployer-lan.conf create mode 100644 deploy/systemd/genarrative-preview-deployer.service create mode 100644 docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md create mode 100644 jenkins/Jenkinsfile.preview-deployer create mode 100644 jenkins/preview-deployer-job-config.xml create mode 100644 scripts/check-preview-deployer.mjs create mode 100644 scripts/deploy/preview-deployer-install.sh create mode 100644 scripts/jenkins-preview-deployer.sh create mode 100644 scripts/preview-deployment-status.mjs create mode 100644 server-rs/crates/preview-deployer-server/Cargo.toml create mode 100644 server-rs/crates/preview-deployer-server/src/config.rs create mode 100644 server-rs/crates/preview-deployer-server/src/jenkins.rs create mode 100644 server-rs/crates/preview-deployer-server/src/lib.rs create mode 100644 server-rs/crates/preview-deployer-server/src/main.rs create mode 100644 server-rs/crates/preview-deployer-server/src/tests.rs diff --git a/.gitignore b/.gitignore index b3dd2a2dc..9ef28446b 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/apps/preview-deployer-web/index.html b/apps/preview-deployer-web/index.html new file mode 100644 index 000000000..6efc9d5e2 --- /dev/null +++ b/apps/preview-deployer-web/index.html @@ -0,0 +1,13 @@ + + + + + + + Docker 预览发布 + + +
+ + + diff --git a/apps/preview-deployer-web/package.json b/apps/preview-deployer-web/package.json new file mode 100644 index 000000000..d535b82fd --- /dev/null +++ b/apps/preview-deployer-web/package.json @@ -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" + } +} diff --git a/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx b/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx new file mode 100644 index 000000000..d0b68bc21 --- /dev/null +++ b/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx @@ -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(); + + 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(); + + 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(); + + 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'); + }); +}); diff --git a/apps/preview-deployer-web/src/PreviewDeployerApp.tsx b/apps/preview-deployer-web/src/PreviewDeployerApp.tsx new file mode 100644 index 000000000..290e6d3cf --- /dev/null +++ b/apps/preview-deployer-web/src/PreviewDeployerApp.tsx @@ -0,0 +1,665 @@ +import { + Activity, + Box, + CheckCircle2, + Clock3, + ExternalLink, + GitBranch, + Hash, + LoaderCircle, + RefreshCw, + Server, + Trash2, + TriangleAlert, + XCircle, +} from 'lucide-react'; +import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react'; + +import { + createDeployment, + createSession, + deleteSession, + getSession, + listDeployments, + PreviewDeployerApiError, + uninstallDeployment, +} from './api'; +import type { + DeploymentHealth, + DeploymentStatus, + PreviewDeployment, +} from './types'; +import { validateBranch, validateCommitHash } from './validation'; + +const POLL_INTERVAL_MS = 5000; + +const STATUS_LABELS: Record = { + queued: '排队中', + building: '构建中', + deploying: '发布中', + running: '运行中', + uninstalling: '卸载中', + stopped: '已卸载', + failed: '失败', + cancelled: '已取消', +}; + +const HEALTH_LABELS: Record = { + pending: '等待检查', + healthy: '健康', + unhealthy: '不健康', + unknown: '未知', +}; + +export function PreviewDeployerApp() { + const [sessionStatus, setSessionStatus] = useState< + 'checking' | 'guest' | 'authenticated' + >('checking'); + const [accessToken, setAccessToken] = useState(''); + const [authenticating, setAuthenticating] = useState(false); + const [authError, setAuthError] = useState(''); + const [branch, setBranch] = useState('master'); + const [commitHash, setCommitHash] = useState(''); + const [deployments, setDeployments] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [notice, setNotice] = useState(''); + const [error, setError] = useState(''); + const [pendingUninstall, setPendingUninstall] = + useState(null); + const [uninstallingId, setUninstallingId] = useState(''); + + const refresh = useCallback(async (background = false) => { + if (!background) { + setRefreshing(true); + } + try { + const nextDeployments = await listDeployments(); + setDeployments(sortDeployments(nextDeployments)); + setError(''); + } catch (refreshError) { + if (isUnauthorized(refreshError)) { + setSessionStatus('guest'); + setDeployments([]); + setError(''); + return; + } + setError(formatError(refreshError)); + } finally { + setLoading(false); + setRefreshing(false); + } + }, []); + + useEffect(() => { + const controller = new AbortController(); + void getSession(controller.signal) + .then((session) => { + if (session.authenticated) { + setSessionStatus('authenticated'); + void refresh(true); + } else { + setSessionStatus('guest'); + setLoading(false); + } + }) + .catch((sessionError) => { + if (isUnauthorized(sessionError)) { + setSessionStatus('guest'); + setLoading(false); + return; + } + setAuthError(formatError(sessionError)); + setSessionStatus('guest'); + setLoading(false); + }); + return () => controller.abort(); + }, [refresh]); + + useEffect(() => { + if (sessionStatus !== 'authenticated') { + return; + } + const interval = window.setInterval(() => { + if (document.visibilityState === 'visible') { + void refresh(true); + } + }, POLL_INTERVAL_MS); + return () => window.clearInterval(interval); + }, [refresh, sessionStatus]); + + const runningCount = useMemo( + () => + deployments.filter((deployment) => deployment.status === 'running') + .length, + [deployments], + ); + const activeCount = useMemo( + () => + deployments.filter((deployment) => isActive(deployment.status)).length, + [deployments], + ); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + const branchError = validateBranch(branch); + const commitError = validateCommitHash(commitHash); + if (branchError || commitError) { + setError(branchError || commitError); + return; + } + + setSubmitting(true); + setError(''); + setNotice(''); + try { + const trimmedCommit = commitHash.trim(); + await createDeployment({ + branch: branch.trim(), + ...(trimmedCommit ? { commitHash: trimmedCommit } : {}), + }); + setCommitHash(''); + setNotice('构建请求已提交'); + await refresh(true); + } catch (submitError) { + if (isUnauthorized(submitError)) { + handleUnauthorized(); + return; + } + setError(formatError(submitError)); + } finally { + setSubmitting(false); + } + } + + async function handleUninstall() { + if (!pendingUninstall) { + return; + } + const deployment = pendingUninstall; + setPendingUninstall(null); + setUninstallingId(deployment.id); + setError(''); + setNotice(''); + try { + await uninstallDeployment(deployment.id); + setNotice(`已提交 ${deployment.branch} 的卸载请求`); + await refresh(true); + } catch (uninstallError) { + if (isUnauthorized(uninstallError)) { + handleUnauthorized(); + return; + } + setError(formatError(uninstallError)); + } finally { + setUninstallingId(''); + } + } + + async function handleLogin(event: FormEvent) { + event.preventDefault(); + const token = accessToken.trim(); + if (!token) { + setAuthError('请输入访问口令'); + return; + } + setAuthenticating(true); + setAuthError(''); + try { + const session = await createSession(token); + if (!session.authenticated) { + throw new Error('访问口令无效'); + } + setAccessToken(''); + setSessionStatus('authenticated'); + setLoading(true); + await refresh(true); + } catch (loginError) { + setAuthError( + isUnauthorized(loginError) ? '访问口令无效' : formatError(loginError), + ); + } finally { + setAuthenticating(false); + } + } + + async function handleLogout() { + try { + await deleteSession(); + } finally { + handleUnauthorized(); + } + } + + function handleUnauthorized() { + setSessionStatus('guest'); + setDeployments([]); + setAccessToken(''); + setError(''); + setNotice(''); + setPendingUninstall(null); + } + + if (sessionStatus === 'checking') { + return ( +
+ + 正在校验访问会话 +
+ ); + } + + if (sessionStatus === 'guest') { + return ( +
+
void handleLogin(event)} + > + + + +
+

PREVIEW DEPLOYER

+

Docker 预览发布

+
+ + {authError ? ( +
+ + {authError} +
+ ) : null} + +
+
+ ); + } + + return ( +
+
+
+ + + +
+ Docker 预览发布 + 内网环境 +
+
+
+ + +
+
+ +
+
+
+

PREVIEW DEPLOYER

+

构建并发布一个分支

+
+
+ } + label="进行中" + value={activeCount} + /> + } + label="运行中" + value={runningCount} + /> +
+
+ +
void handleSubmit(event)} + > + + + +
+ + {error ? ( +
+ + {error} +
+ ) : null} + {notice ? ( +
+ + {notice} +
+ ) : null} + +
+
+
+

发布记录

+

状态每 5 秒自动刷新

+
+
+ + {loading ? ( +
+ + 正在读取发布状态 +
+ ) : deployments.length === 0 ? ( +
+ + 还没有发布记录 +
+ ) : ( +
+ {deployments.map((deployment) => ( + setPendingUninstall(deployment)} + /> + ))} +
+ )} +
+
+ + {pendingUninstall ? ( +
setPendingUninstall(null)} + > +
event.stopPropagation()} + > + + + +

卸载这个预览?

+

+ 将停止并移除 {pendingUninstall.branch}{' '} + 对应的容器环境。 +

+
+ + +
+
+
+ ) : null} +
+ ); +} + +function DeploymentCard({ + deployment, + uninstalling, + onUninstall, +}: { + deployment: PreviewDeployment; + uninstalling: boolean; + onUninstall: () => void; +}) { + const displayedCommit = deployment.resolvedCommit || deployment.commitHash; + const canUninstall = + deployment.canUninstall ?? + !['stopped', 'uninstalling', 'cancelled'].includes(deployment.status); + + return ( +
+
+
+ + + +
+ + + {deployment.branch} + + + {displayedCommit ? shortCommit(displayedCommit) : '分支最新提交'} + +
+
+
+ + {STATUS_LABELS[deployment.status]} + + + + {HEALTH_LABELS[deployment.health]} + +
+
+ +
+ + + {formatTime(deployment.updatedAt || deployment.createdAt)} + + {deployment.message ? ( + {deployment.message} + ) : null} +
+ +
+ {deployment.webUrl ? ( + + 打开 Web + + ) : ( + Web 地址等待发布完成 + )} +
+ {deployment.jenkinsBuildUrl ? ( + + 构建详情 + + ) : null} + {canUninstall ? ( + + ) : null} +
+
+
+ ); +} + +function StatusIcon({ status }: { status: DeploymentStatus }) { + if (['queued', 'building', 'deploying', 'uninstalling'].includes(status)) { + return ; + } + if (status === 'running') { + return ; + } + if (status === 'failed' || status === 'cancelled') { + return ; + } + return ; +} + +function Summary({ + icon, + label, + value, +}: { + icon: React.ReactNode; + label: string; + value: number; +}) { + return ( + + {icon} + {value} + {label} + + ); +} + +function isActive(status: DeploymentStatus) { + return ['queued', 'building', 'deploying', 'uninstalling'].includes(status); +} + +function sortDeployments(deployments: PreviewDeployment[]) { + return [...deployments].sort( + (left, right) => toTimestamp(right.createdAt) - toTimestamp(left.createdAt), + ); +} + +function shortCommit(value: string) { + return value.slice(0, 10); +} + +function formatTime(value: string | number) { + const timestamp = toTimestamp(value); + if (Number.isNaN(timestamp)) { + return String(value || '时间未知'); + } + return new Intl.DateTimeFormat('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }).format(timestamp); +} + +function toTimestamp(value: string | number) { + if (typeof value === 'number') { + return value < 10_000_000_000 ? value * 1000 : value; + } + return Date.parse(value); +} + +function formatError(error: unknown) { + return error instanceof Error && error.message.trim() + ? error.message + : '请求失败'; +} + +function isUnauthorized(error: unknown) { + return error instanceof PreviewDeployerApiError && error.status === 401; +} diff --git a/apps/preview-deployer-web/src/api.test.ts b/apps/preview-deployer-web/src/api.test.ts new file mode 100644 index 000000000..6e0595ed4 --- /dev/null +++ b/apps/preview-deployer-web/src/api.test.ts @@ -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' }), + ); +}); diff --git a/apps/preview-deployer-web/src/api.ts b/apps/preview-deployer-web/src/api.ts new file mode 100644 index 000000000..8c980743b --- /dev/null +++ b/apps/preview-deployer-web/src/api.ts @@ -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('/session', { + signal, + }); + return session ?? { authenticated: true }; +} + +export function createSession(accessToken: string) { + return request('/session', { + method: 'POST', + body: { accessToken }, + }); +} + +export function deleteSession() { + return request('/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('/deployments', { + method: 'POST', + body: input, + }); +} + +export function uninstallDeployment(deploymentId: string) { + return request( + `/deployments/${encodeURIComponent(deploymentId)}/uninstall`, + { method: 'POST', body: {} }, + ); +} + +interface RequestOptions { + method?: string; + body?: unknown; + signal?: AbortSignal; +} + +async function request( + path: string, + options: RequestOptions = {}, +): Promise { + const headers: Record = { 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(payload); +} + +function parseJson(value: string): unknown { + if (!value.trim()) { + return null; + } + try { + return JSON.parse(value) as unknown; + } catch { + return value; + } +} + +function unwrapPayload(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 { + return typeof value === 'object' && value !== null; +} diff --git a/apps/preview-deployer-web/src/main.tsx b/apps/preview-deployer-web/src/main.tsx new file mode 100644 index 000000000..8217f6507 --- /dev/null +++ b/apps/preview-deployer-web/src/main.tsx @@ -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( + + + , +); diff --git a/apps/preview-deployer-web/src/styles.css b/apps/preview-deployer-web/src/styles.css new file mode 100644 index 000000000..3316766b6 --- /dev/null +++ b/apps/preview-deployer-web/src/styles.css @@ -0,0 +1,613 @@ +:root { + color: #1c2533; + background: #f3f6fa; + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; + font-synthesis: none; + text-rendering: optimizeLegibility; +} + +* { + box-sizing: border-box; +} +body { + min-width: 320px; + min-height: 100vh; + margin: 0; + background: #f3f6fa; +} +button, +input { + font: inherit; +} +button { + cursor: pointer; +} +button:disabled { + cursor: not-allowed; + opacity: 0.6; +} +a { + color: inherit; +} + +.session-screen { + display: grid; + min-height: 100dvh; + place-content: center; + justify-items: center; + gap: 11px; + color: #778397; + padding: 20px; +} +.session-login-screen { + background: radial-gradient( + circle at 50% 20%, + rgba(37, 99, 235, 0.12), + transparent 34% + ), + #f3f6fa; +} +.login-card { + display: grid; + width: min(100vw - 32px, 400px); + gap: 19px; + border: 1px solid #dbe2eb; + border-radius: 16px; + background: #fff; + padding: 28px; + box-shadow: 0 24px 70px rgba(28, 37, 51, 0.13); +} +.login-card h1 { + margin: 5px 0 0; + color: #1c2533; + font-size: 25px; + letter-spacing: -0.025em; +} +.login-card label { + display: grid; + gap: 8px; + color: #39465a; + font-size: 13px; + font-weight: 700; +} +.login-brand-mark { + width: 44px; + height: 44px; +} +.login-error { + display: flex; + align-items: center; + gap: 7px; + color: #b42318; + font-size: 12px; +} + +.app-shell { + min-height: 100vh; +} +.topbar { + position: sticky; + top: 0; + z-index: 10; + display: flex; + min-height: 64px; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid #dde3eb; + background: rgba(255, 255, 255, 0.92); + padding: 0 max(20px, calc((100vw - 1120px) / 2)); + backdrop-filter: blur(12px); +} +.brand { + display: flex; + align-items: center; + gap: 11px; +} +.brand-mark { + display: grid; + width: 38px; + height: 38px; + place-items: center; + border-radius: 10px; + color: #fff; + background: #2563eb; + box-shadow: 0 6px 16px rgba(37, 99, 235, 0.24); +} +.brand div { + display: grid; + gap: 1px; +} +.brand strong { + font-size: 15px; +} +.brand div span { + color: #778397; + font-size: 11px; +} +.topbar-actions { + display: flex; + gap: 8px; +} +.content { + width: min(1120px, calc(100% - 40px)); + margin: 0 auto; + padding: 42px 0 64px; +} +.hero { + display: flex; + align-items: end; + justify-content: space-between; + gap: 24px; + margin-bottom: 24px; +} +.eyebrow { + margin: 0 0 7px; + color: #2563eb; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.12em; +} +.hero h1 { + margin: 0; + font-size: clamp(27px, 4vw, 36px); + letter-spacing: -0.035em; +} +.summary-row { + display: flex; + gap: 9px; +} +.summary-chip { + display: flex; + min-height: 38px; + align-items: center; + gap: 7px; + border: 1px solid #dde3eb; + border-radius: 10px; + color: #657188; + background: #fff; + padding: 0 12px; + font-size: 12px; +} +.summary-chip strong { + color: #1c2533; + font-size: 15px; +} +.deploy-form { + display: grid; + grid-template-columns: minmax(220px, 1fr) minmax(260px, 1.2fr) auto; + align-items: end; + gap: 16px; + border: 1px solid #dbe2eb; + border-radius: 14px; + background: #fff; + padding: 22px; + box-shadow: 0 12px 32px rgba(28, 37, 51, 0.055); +} +.deploy-form label { + display: grid; + gap: 8px; + color: #39465a; + font-size: 13px; + font-weight: 700; +} +.deploy-form label small { + color: #8994a5; + font-weight: 500; +} +.input-shell { + display: flex; + height: 44px; + align-items: center; + gap: 8px; + border: 1px solid #cfd7e3; + border-radius: 9px; + color: #8994a5; + padding: 0 12px; + transition: + border-color 0.15s, + box-shadow 0.15s; +} +.input-shell:focus-within { + border-color: #4b7bec; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.11); +} +.input-shell input { + width: 100%; + min-width: 0; + border: 0; + outline: 0; + color: #202b3b; + background: transparent; +} +.input-shell input::placeholder { + color: #a4adba; +} +.primary-button, +.secondary-button, +.danger-button, +.icon-danger-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + border-radius: 9px; + font-weight: 700; +} +.primary-button { + height: 44px; + border: 1px solid #2563eb; + color: #fff; + background: #2563eb; + padding: 0 18px; + box-shadow: 0 6px 15px rgba(37, 99, 235, 0.2); +} +.primary-button:hover { + background: #1d55ca; +} +.secondary-button { + min-height: 38px; + border: 1px solid #d5dce6; + color: #455266; + background: #fff; + padding: 0 13px; +} +.secondary-button:hover { + background: #f7f9fc; +} +.flash { + display: flex; + align-items: center; + gap: 9px; + margin-top: 14px; + border-radius: 9px; + padding: 11px 13px; + font-size: 13px; +} +.flash-error { + border: 1px solid #fecaca; + color: #b42318; + background: #fff3f2; +} +.flash-success { + border: 1px solid #bbf7d0; + color: #167044; + background: #f0fdf4; +} +.deployments-section { + margin-top: 38px; +} +.section-heading { + display: flex; + align-items: end; + justify-content: space-between; + margin-bottom: 14px; +} +.section-heading h2 { + margin: 0 0 4px; + font-size: 19px; +} +.section-heading p { + margin: 0; + color: #8490a2; + font-size: 12px; +} +.deployment-list { + display: grid; + gap: 11px; +} +.deployment-card { + border: 1px solid #dce3ec; + border-radius: 12px; + background: #fff; + padding: 17px 18px 14px; + box-shadow: 0 4px 14px rgba(28, 37, 51, 0.035); +} +.card-main { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; +} +.deployment-title-row { + display: flex; + min-width: 0; + align-items: center; + gap: 11px; +} +.status-icon { + display: grid; + width: 36px; + height: 36px; + flex: 0 0 auto; + place-items: center; + border-radius: 9px; + color: #64748b; + background: #f0f3f7; +} +.status-running { + color: #168052; + background: #eafaf1; +} +.status-failed, +.status-cancelled { + color: #c43b32; + background: #fff0ef; +} +.status-building, +.status-deploying, +.status-queued, +.status-uninstalling { + color: #2563eb; + background: #edf4ff; +} +.deployment-title { + display: grid; + min-width: 0; + gap: 4px; +} +.deployment-title strong { + display: flex; + align-items: center; + gap: 6px; + overflow: hidden; + font-size: 15px; + text-overflow: ellipsis; + white-space: nowrap; +} +.deployment-title span { + color: #7c8798; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; +} +.badges { + display: flex; + flex: 0 0 auto; + gap: 7px; +} +.badge { + display: inline-flex; + min-height: 25px; + align-items: center; + gap: 6px; + border-radius: 999px; + padding: 0 9px; + font-size: 11px; + font-weight: 700; +} +.badge-running { + color: #14734a; + background: #e9f9f0; +} +.badge-failed, +.badge-cancelled { + color: #b83129; + background: #fff0ef; +} +.badge-building, +.badge-deploying, +.badge-queued, +.badge-uninstalling { + color: #275db9; + background: #edf4ff; +} +.badge-stopped { + color: #667085; + background: #f0f2f5; +} +.health-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} +.health-healthy { + color: #14734a; + background: #e9f9f0; +} +.health-unhealthy { + color: #b83129; + background: #fff0ef; +} +.health-pending, +.health-unknown { + color: #7b6650; + background: #f8f3eb; +} +.deployment-meta { + display: flex; + align-items: center; + gap: 12px; + margin: 13px 0 0 47px; + color: #8792a2; + font-size: 11px; +} +.deployment-meta > span { + display: inline-flex; + align-items: center; + gap: 5px; +} +.deployment-message { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.card-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + margin-top: 14px; + border-top: 1px solid #edf0f4; + padding-top: 12px; +} +.primary-link, +.text-link { + display: inline-flex; + align-items: center; + gap: 5px; + color: #2563eb; + font-size: 12px; + font-weight: 700; + text-decoration: none; +} +.primary-link:hover, +.text-link:hover { + text-decoration: underline; +} +.url-placeholder { + color: #98a2b2; + font-size: 12px; +} +.right-actions { + display: flex; + align-items: center; + gap: 15px; +} +.icon-danger-button { + min-height: 32px; + border: 1px solid transparent; + color: #b93a31; + background: transparent; + padding: 0 8px; + font-size: 12px; +} +.icon-danger-button:hover { + border-color: #ffd4d1; + background: #fff4f3; +} +.empty-state { + display: flex; + min-height: 170px; + align-items: center; + justify-content: center; + gap: 10px; + border: 1px dashed #cfd7e2; + border-radius: 12px; + color: #8490a2; + background: rgba(255, 255, 255, 0.55); + font-size: 13px; +} +.modal-backdrop { + position: fixed; + inset: 0; + z-index: 30; + display: grid; + place-items: center; + background: rgba(21, 29, 42, 0.42); + padding: 20px; + backdrop-filter: blur(3px); +} +.confirm-dialog { + width: min(100%, 410px); + border: 1px solid #e2e7ee; + border-radius: 14px; + background: #fff; + padding: 24px; + box-shadow: 0 24px 70px rgba(15, 23, 42, 0.24); +} +.danger-icon { + display: grid; + width: 42px; + height: 42px; + place-items: center; + border-radius: 11px; + color: #bd382f; + background: #fff0ef; +} +.confirm-dialog h2 { + margin: 17px 0 8px; + font-size: 20px; +} +.confirm-dialog p { + margin: 0; + color: #667085; + font-size: 14px; + line-height: 1.65; +} +.dialog-actions { + display: flex; + justify-content: flex-end; + gap: 9px; + margin-top: 24px; +} +.danger-button { + min-height: 38px; + border: 1px solid #c33b32; + color: #fff; + background: #c33b32; + padding: 0 13px; +} +.spin { + animation: spin 0.8s linear infinite; +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@media (max-width: 760px) { + .topbar { + padding: 0 16px; + } + .content { + width: min(100% - 28px, 1120px); + padding-top: 28px; + } + .hero { + align-items: flex-start; + flex-direction: column; + } + .deploy-form { + grid-template-columns: 1fr; + padding: 17px; + } + .card-main { + align-items: flex-start; + flex-direction: column; + gap: 12px; + } + .badges { + margin-left: 47px; + } + .card-actions { + align-items: flex-start; + flex-direction: column; + } + .right-actions { + width: 100%; + justify-content: space-between; + } +} + +@media (max-width: 430px) { + .brand div span, + .secondary-button { + font-size: 0; + } + .secondary-button { + width: 38px; + padding: 0; + } + .summary-row { + width: 100%; + } + .summary-chip { + flex: 1; + } + .badges { + margin-left: 0; + } + .deployment-meta { + align-items: flex-start; + flex-direction: column; + margin-left: 0; + } +} diff --git a/apps/preview-deployer-web/src/types.ts b/apps/preview-deployer-web/src/types.ts new file mode 100644 index 000000000..d538aee26 --- /dev/null +++ b/apps/preview-deployer-web/src/types.ts @@ -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; +} diff --git a/apps/preview-deployer-web/src/validation.test.ts b/apps/preview-deployer-web/src/validation.test.ts new file mode 100644 index 000000000..dc3c85690 --- /dev/null +++ b/apps/preview-deployer-web/src/validation.test.ts @@ -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(''); +}); diff --git a/apps/preview-deployer-web/src/validation.ts b/apps/preview-deployer-web/src/validation.ts new file mode 100644 index 000000000..ad363332a --- /dev/null +++ b/apps/preview-deployer-web/src/validation.ts @@ -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 ''; +} diff --git a/apps/preview-deployer-web/src/vite-env.d.ts b/apps/preview-deployer-web/src/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/apps/preview-deployer-web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/preview-deployer-web/tsconfig.json b/apps/preview-deployer-web/tsconfig.json new file mode 100644 index 000000000..8fb985872 --- /dev/null +++ b/apps/preview-deployer-web/tsconfig.json @@ -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"] +} diff --git a/apps/preview-deployer-web/vite.config.ts b/apps/preview-deployer-web/vite.config.ts new file mode 100644 index 000000000..99f2728cd --- /dev/null +++ b/apps/preview-deployer-web/vite.config.ts @@ -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, + }, + }; +}); diff --git a/apps/preview-deployer-web/vitest.config.ts b/apps/preview-deployer-web/vitest.config.ts new file mode 100644 index 000000000..c4588ab8c --- /dev/null +++ b/apps/preview-deployer-web/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'jsdom', + globals: true, + }, +}); diff --git a/deploy/container/docker-compose.loadtest.yml b/deploy/container/docker-compose.loadtest.yml index 1268bc485..93475d8d9 100644 --- a/deploy/container/docker-compose.loadtest.yml +++ b/deploy/container/docker-compose.loadtest.yml @@ -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 diff --git a/deploy/env/preview-deployer.env.example b/deploy/env/preview-deployer.env.example new file mode 100644 index 000000000..629505d7e --- /dev/null +++ b/deploy/env/preview-deployer.env.example @@ -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 diff --git a/deploy/nginx/genarrative-preview-deployer-lan.conf b/deploy/nginx/genarrative-preview-deployer-lan.conf new file mode 100644 index 000000000..82e698937 --- /dev/null +++ b/deploy/nginx/genarrative-preview-deployer-lan.conf @@ -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; +} diff --git a/deploy/systemd/genarrative-preview-deployer.service b/deploy/systemd/genarrative-preview-deployer.service new file mode 100644 index 000000000..01e9990f1 --- /dev/null +++ b/deploy/systemd/genarrative-preview-deployer.service @@ -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 diff --git a/docs/README.md b/docs/README.md index 11600184b..471c17e62 100644 --- a/docs/README.md +++ b/docs/README.md @@ -45,6 +45,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) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index fae730801..bc671a867 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -14153,3 +14153,11 @@ - Windows 锁文件决策:提升权限进程新建 `.agent/.manifest.json.lock` 时,Windows 可能把 owner 设为 `Administrators`。仅在固定锁路径已取得不共享独占句柄并确认是普通、非 reparse、单链接文件后,才初始化为当前 `TokenUser`;随后再次复核句柄并执行原有 owner/DACL 校验,不放宽既有异常对象的安全规则。 - Provider Schema 决策:`agent.route_manifest.missingAssetSlots` 不再广告 OpenAI-compatible 代理拒绝的 `uniqueItems`;Runtime 继续排序去重,Schema 子集门禁新增该关键字,真实 Provider smoke 必须在发布前证明工具目录可被接受。 - 运行决策:Godot 项目提交给 Project Supervisor 时使用 `standard` Run Profile,避免触发 Web 专用 `game/index.html`、HTTP preview 与自主 Web 完成门。Godot 编辑器启动和内嵌运行预览不在本切片范围。 +## 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`。 diff --git a/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md b/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md new file mode 100644 index 000000000..23436d15c --- /dev/null +++ b/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-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://<预览宿主>:`。 + +## 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 支持动态 Crumb;API 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`。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index e00c42bc1..8024f7892 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -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 Token;SPA 通过同源代理触发固定 `shared/Genarrative-Preview-Deployer` Job。每个分支使用稳定 `deploymentId` 和独立 Compose project,Web 端口从 `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 排空另行验证。 diff --git a/jenkins/Jenkinsfile.preview-deployer b/jenkins/Jenkinsfile.preview-deployer new file mode 100644 index 000000000..6591da10f --- /dev/null +++ b/jenkins/Jenkinsfile.preview-deployer @@ -0,0 +1,147 @@ +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 + 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 + } + } +} diff --git a/jenkins/preview-deployer-job-config.xml b/jenkins/preview-deployer-job-config.xml new file mode 100644 index 000000000..a2317950f --- /dev/null +++ b/jenkins/preview-deployer-job-config.xml @@ -0,0 +1,43 @@ + + + + 内网分支容器预览,仅由 Preview Deployer 控制面触发。 + false + + + + + + + + 2 + + + ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git + genarrative-local-gitea-ssh + +refs/heads/master:refs/remotes/origin/master + + + + + */master + + + false + + + + true + true + + 1 + true + + + + jenkins/Jenkinsfile.preview-deployer + false + + + false + diff --git a/package.json b/package.json index d57f551fe..8e6902e9d 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,11 @@ "dev:web": "node scripts/dev.mjs web", "dev:admin-web": "node scripts/dev.mjs admin-web", "server-manager:panel": "cargo run -p server-manager-panel --manifest-path server-rs/Cargo.toml", + "preview-deployer:server": "cargo run -p preview-deployer-server --manifest-path server-rs/Cargo.toml", + "preview-deployer:web:dev": "npm --prefix apps/preview-deployer-web run dev", + "preview-deployer:web:typecheck": "npm --prefix apps/preview-deployer-web run typecheck", + "preview-deployer:web:test": "npm --prefix apps/preview-deployer-web run test", + "preview-deployer:web:build": "npm --prefix apps/preview-deployer-web run build", "dev:spacetime:logs": "node scripts/run-bash-script.mjs scripts/spacetime-logs-local.sh", "otel:debug": "node scripts/run-otelcol.mjs debug", "otel:rider": "node scripts/run-otelcol.mjs rider", @@ -46,6 +51,7 @@ "check:rustfmt": "cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check", "check:spacetime-schema": "node scripts/check-spacetime-schema-guard.mjs", "check:production-ops": "node scripts/check-production-ops-guardrails.mjs", + "check:preview-deployer": "node scripts/check-preview-deployer.mjs", "check:maintenance-page": "node scripts/check-maintenance-page.mjs", "check:database-backup": "node scripts/check-database-backup-to-oss.mjs", "check:production-health-patrol": "node scripts/check-production-health-patrol.mjs", @@ -93,7 +99,7 @@ "lint:guardrails": "npm run lint:eslint", "typecheck": "tsc -p tsconfig.typecheck-guardrails.json --noEmit", "typecheck:guardrails": "npm run typecheck", - "lint": "npm run check:encoding && npm run check:pre-commit-format && npm run check:rustfmt && npm run check:spacetime-schema && npm run check:production-ops && npm run check:maintenance-page && npm run lint:eslint && npm run typecheck", + "lint": "npm run check:encoding && npm run check:pre-commit-format && npm run check:rustfmt && npm run check:spacetime-schema && npm run check:production-ops && npm run check:preview-deployer && npm run check:maintenance-page && npm run lint:eslint && npm run typecheck", "lint:fix": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --fix && prettier --write .", "format": "prettier --write .", "format:check": "prettier --check .", diff --git a/scripts/check-preview-deployer.mjs b/scripts/check-preview-deployer.mjs new file mode 100644 index 000000000..603956cb4 --- /dev/null +++ b/scripts/check-preview-deployer.mjs @@ -0,0 +1,180 @@ +import { readFileSync } from 'node:fs'; + +const jenkinsfile = readFileSync( + 'jenkins/Jenkinsfile.preview-deployer', + 'utf8', +); +const deployer = readFileSync('scripts/jenkins-preview-deployer.sh', 'utf8'); +const resultWriter = readFileSync( + 'scripts/preview-deployment-status.mjs', + 'utf8', +); +const compose = readFileSync( + 'deploy/container/docker-compose.loadtest.yml', + 'utf8', +); +const webConfig = readFileSync( + 'apps/preview-deployer-web/vite.config.ts', + 'utf8', +); +const systemd = readFileSync( + 'deploy/systemd/genarrative-preview-deployer.service', + 'utf8', +); +const nginx = readFileSync( + 'deploy/nginx/genarrative-preview-deployer-lan.conf', + 'utf8', +); +const jobConfig = readFileSync( + 'jenkins/preview-deployer-job-config.xml', + 'utf8', +); + +const failures = []; +assertIncludes( + jenkinsfile, + "choice(name: 'ACTION', choices: ['DEPLOY', 'STATUS', 'UNINSTALL']", + 'Jenkins 参数必须固定为 DEPLOY/STATUS/UNINSTALL。', +); +assertIncludes( + jenkinsfile, + "string(name: 'SOURCE_BRANCH'", + 'Jenkins 必须暴露 SOURCE_BRANCH。', +); +assertIncludes( + jenkinsfile, + "string(name: 'COMMIT_HASH'", + 'Jenkins 必须暴露 COMMIT_HASH。', +); +assertIncludes( + jenkinsfile, + "string(name: 'DEPLOYMENT_ID'", + 'Jenkins 必须暴露 DEPLOYMENT_ID。', +); +assertIncludes( + jenkinsfile, + "archiveArtifacts artifacts: 'preview-result.json'", + 'Jenkins 必须归档 preview-result.json。', +); +assertIncludes( + jenkinsfile, + "writeFile file: 'preview-result.json'", + 'Jenkins 失败路径也必须生成 preview-result.json。', +); +assertIncludes( + jenkinsfile, + 'disableConcurrentBuilds()', + 'Jenkins Job 必须禁止并发构建。', +); +assertIncludes(deployer, 'flock -x 9', '部署脚本必须使用跨进程独占锁。'); +assertIncludes( + deployer, + 'git check-ref-format --branch', + '部署脚本必须校验分支格式。', +); +assertIncludes( + deployer, + 'genarrative-preview-[0-9a-f]{16}', + '卸载必须限制到预览 compose project 白名单。', +); +assertIncludes( + deployer, + 'allocate_port webPort 8400 8499', + 'Web 端口必须从 8400-8499 分配。', +); +assertIncludes( + deployer, + 'ports: !reset []', + '预览 compose override 必须取消 SpacetimeDB 和 OTLP 宿主端口映射。', +); +assertIncludes( + deployer, + '^preview-[0-9a-f]{16}$', + '部署 ID 必须固定为 preview- 加 16 位摘要。', +); +assertIncludes( + deployer, + 'state.active === true && String(state[key]) === expected', + '只有活动实例才能占用端口租约。', +); +assertIncludes( + deployer, + '--yes=migrate,break-clients', + 'SpacetimeDB 发布不得默认删除实例数据。', +); +assertExcludes( + deployer, + '--delete-data', + '预览部署不得使用 SpacetimeDB 删除数据参数。', +); +assertIncludes(webConfig, "?? '/build/'", 'SPA 默认 base 必须固定为 /build/。'); +assertIncludes( + systemd, + 'User=jenkins', + '控制服务必须使用 Jenkins 运行用户以访问受控构建状态。', +); +assertIncludes( + nginx, + 'location ^~ /build/', + '内网 Nginx 必须提供 /build/ 路由。', +); +assertIncludes( + jobConfig, + 'jenkins/Jenkinsfile.preview-deployer', + 'Jenkins Job 必须从受保护 master 读取固定流水线。', +); +assertIncludes( + nginx, + 'location ^~ /api/preview-deployer/', + '内网 Nginx 必须代理预览控制 API。', +); +assertIncludes( + resultWriter, + 'schemaVersion: 1', + '结果文件必须带版本化 schema。', +); +assertIncludes(resultWriter, 'healthStatus', '结果文件必须提供健康状态。'); +assertIncludes(resultWriter, 'webUrl', '结果文件必须提供 Web URL。'); +assertIncludes( + resultWriter, + 'resolvedCommit', + '结果文件必须提供 resolvedCommit。', +); +assertIncludes( + resultWriter, + 'status: publicStatus(phase)', + '结果文件必须提供小写 status 契约。', +); +assertIncludes( + resultWriter, + 'health: publicHealth(healthStatus)', + '结果文件必须提供小写 health 契约。', +); +assertIncludes( + compose, + 'test: ["CMD", "wget", "-qO-", "http://127.0.0.1/"]', + 'Nginx 容器健康检查必须验证 Web 首页。', +); +assertExcludes( + compose, + 'test: ["CMD", "wget", "-qO-", "http://127.0.0.1/healthz"]', + 'Nginx 容器健康检查不能访问公开拒绝的 /healthz。', +); + +if (failures.length > 0) { + for (const failure of failures) + console.error(`[check:preview-deployer] ${failure}`); + process.exit(1); +} + +console.log( + '[check:preview-deployer] Jenkins 参数、隔离部署、卸载白名单、结果契约和 Nginx 健康检查通过。', +); + +function assertIncludes(content, expected, message) { + if (!content.includes(expected)) failures.push(message); +} + +function assertExcludes(content, expected, message) { + if (content.includes(expected)) failures.push(message); +} diff --git a/scripts/deploy/preview-deployer-install.sh b/scripts/deploy/preview-deployer-install.sh new file mode 100644 index 000000000..346588b1c --- /dev/null +++ b/scripts/deploy/preview-deployer-install.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +INSTALL_ROOT="${GENARRATIVE_PREVIEW_DEPLOYER_INSTALL_ROOT:-/opt/genarrative/preview-deployer}" +STATE_ROOT="${GENARRATIVE_PREVIEW_DEPLOYER_STATE_ROOT:-/var/lib/genarrative/preview-deployer}" +ENV_FILE="${GENARRATIVE_PREVIEW_DEPLOYER_ENV_FILE:-/etc/genarrative/preview-deployer.env}" +UNIT_FILE="${GENARRATIVE_PREVIEW_DEPLOYER_UNIT_FILE:-/etc/systemd/system/genarrative-preview-deployer.service}" + +if [[ "${EUID}" -ne 0 ]]; then + echo "[preview-deployer-install] 必须以 root 执行安装。" >&2 + exit 1 +fi +if [[ ! -f "${ENV_FILE}" ]]; then + echo "[preview-deployer-install] 缺少私密配置 ${ENV_FILE};请从 deploy/env/preview-deployer.env.example 创建并填入 Jenkins Token 和控制面口令。" >&2 + exit 1 +fi + +cd "${ROOT_DIR}" +PREVIEW_DEPLOYER_WEB_BASE=/build/ npm run preview-deployer:web:build +cargo build -p preview-deployer-server --release --manifest-path server-rs/Cargo.toml + +install -d -o jenkins -g jenkins -m 0750 "${INSTALL_ROOT}" "${INSTALL_ROOT}/web" +install -d -o jenkins -g jenkins -m 0700 "${STATE_ROOT}" +install -o root -g root -m 0755 \ + server-rs/target/release/preview-deployer-server \ + "${INSTALL_ROOT}/preview-deployer-server" +find "${INSTALL_ROOT}/web" -mindepth 1 -delete +cp -a apps/preview-deployer-web/dist/. "${INSTALL_ROOT}/web/" +chown -R root:root "${INSTALL_ROOT}/web" +find "${INSTALL_ROOT}/web" -type d -exec chmod 0755 {} + +find "${INSTALL_ROOT}/web" -type f -exec chmod 0644 {} + +install -o root -g root -m 0644 \ + deploy/systemd/genarrative-preview-deployer.service "${UNIT_FILE}" + +systemctl daemon-reload +systemctl enable --now genarrative-preview-deployer.service +curl --fail --silent --show-error --max-time 5 \ + --header 'Host: 192.168.35.82' http://127.0.0.1:8410/healthz >/dev/null +echo "[preview-deployer-install] 控制服务已安装;请将 deploy/nginx/genarrative-preview-deployer-lan.conf 嵌入内网默认 server 后执行 nginx -t && systemctl reload nginx。" diff --git a/scripts/jenkins-preview-deployer.sh b/scripts/jenkins-preview-deployer.sh new file mode 100644 index 000000000..6a92d6293 --- /dev/null +++ b/scripts/jenkins-preview-deployer.sh @@ -0,0 +1,478 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ACTION="${ACTION:-STATUS}" +SOURCE_BRANCH="${SOURCE_BRANCH:-}" +COMMIT_HASH="${COMMIT_HASH:-}" +DEPLOYMENT_ID="${DEPLOYMENT_ID:-}" +SOURCE_DIR="${SOURCE_DIR:-${WORKSPACE:-$(pwd)}/source}" +RESULT_FILE="${RESULT_FILE:-${WORKSPACE:-$(pwd)}/preview-result.json}" +DESCRIPTION_FILE="${DESCRIPTION_FILE:-${WORKSPACE:-$(pwd)}/.jenkins-preview-description}" +STATE_ROOT="${GENARRATIVE_PREVIEW_STATE_ROOT:-/data/jenkins/preview-deployments}" +WEB_HOST="${GENARRATIVE_PREVIEW_WEB_HOST:-}" +LOCK_FILE="${GENARRATIVE_PREVIEW_LOCK_FILE:-${STATE_ROOT}/.lock}" + +STATE_DIR="" +STATE_FILE="" +PROJECT_NAME="" +SCRIPT_ROOT="" +SCRIPT_FAILED=1 + +fail() { + echo "[preview-deployer] $*" >&2 + exit 1 +} + +validate_request() { + case "${ACTION}" in + DEPLOY|UNINSTALL|STATUS) ;; + *) fail "ACTION 只支持 DEPLOY、UNINSTALL 或 STATUS: ${ACTION}" ;; + esac + + if [[ -n "${SOURCE_BRANCH}" ]]; then + [[ "${SOURCE_BRANCH}" =~ ^[0-9A-Za-z._/-]+$ ]] || fail "SOURCE_BRANCH 包含不允许的字符。" + [[ "${SOURCE_BRANCH}" != /* && "${SOURCE_BRANCH}" != */ && "${SOURCE_BRANCH}" != *..* ]] || \ + fail "SOURCE_BRANCH 不能以斜杠开头/结尾,也不能包含连续点号。" + git check-ref-format --branch "${SOURCE_BRANCH}" >/dev/null 2>&1 || fail "SOURCE_BRANCH 不是合法 Git 分支名。" + fi + + if [[ -n "${COMMIT_HASH}" && ! "${COMMIT_HASH}" =~ ^[0-9a-fA-F]{7,40}$ ]]; then + fail "COMMIT_HASH 只能填写 7 到 40 位十六进制 Git commit hash。" + fi + + if [[ "${ACTION}" == "DEPLOY" && -z "${SOURCE_BRANCH}" ]]; then + fail "DEPLOY 必须填写 SOURCE_BRANCH。" + fi + if [[ "${ACTION}" == "UNINSTALL" && -z "${SOURCE_BRANCH}" ]]; then + fail "UNINSTALL 必须同时填写受控 SOURCE_BRANCH,以验证部署所有权。" + fi + if [[ "${ACTION}" != "DEPLOY" && -z "${SOURCE_BRANCH}" && -z "${DEPLOYMENT_ID}" ]]; then + fail "UNINSTALL/STATUS 至少需要 SOURCE_BRANCH 或 DEPLOYMENT_ID。" + fi + + if [[ -n "${DEPLOYMENT_ID}" && ! "${DEPLOYMENT_ID}" =~ ^preview-[0-9a-f]{16}$ ]]; then + fail "DEPLOYMENT_ID 必须符合 preview- 加 16 位小写十六进制摘要。" + fi +} + +derive_deployment_id() { + local digest + [[ -n "${SOURCE_BRANCH}" ]] || return 0 + digest="$(printf '%s' "${SOURCE_BRANCH}" | sha256sum)" + digest="${digest%% *}" + printf 'preview-%s\n' "${digest:0:16}" +} + +resolve_identity() { + local derived_id="" + derived_id="$(derive_deployment_id)" + if [[ -z "${DEPLOYMENT_ID}" ]]; then + DEPLOYMENT_ID="${derived_id}" + elif [[ -n "${derived_id}" && "${DEPLOYMENT_ID}" != "${derived_id}" ]]; then + fail "DEPLOYMENT_ID 与 SOURCE_BRANCH 的稳定映射不一致,预期 ${derived_id}。" + fi + + [[ -n "${DEPLOYMENT_ID}" ]] || fail "无法确定 DEPLOYMENT_ID。" + PROJECT_NAME="genarrative-${DEPLOYMENT_ID}" + STATE_DIR="${STATE_ROOT}/${DEPLOYMENT_ID}" + STATE_FILE="${STATE_DIR}/state.json" +} + +ensure_safe_state_root() { + [[ "${STATE_ROOT}" == /* && "${STATE_ROOT}" != "/" ]] || fail "状态目录必须是非根目录绝对路径。" + if [[ -L "${STATE_ROOT}" ]]; then + fail "状态目录不能是符号链接: ${STATE_ROOT}" + fi + install -d -m 0700 "${STATE_ROOT}" + if [[ -e "${STATE_DIR}" && ( -L "${STATE_DIR}" || ! -d "${STATE_DIR}" ) ]]; then + fail "部署状态路径必须是普通目录且不能是符号链接: ${STATE_DIR}" + fi + install -d -m 0700 "${STATE_DIR}" +} + +state_value() { + local key="$1" + [[ -f "${STATE_FILE}" && ! -L "${STATE_FILE}" ]] || return 0 + node -e ' + const fs = require("node:fs"); + const value = JSON.parse(fs.readFileSync(process.argv[1], "utf8"))[process.argv[2]]; + if (value !== undefined && value !== null) process.stdout.write(String(value)); + ' "${STATE_FILE}" "${key}" +} + +validate_existing_state() { + local saved_id saved_project saved_branch + [[ -f "${STATE_FILE}" && ! -L "${STATE_FILE}" ]] || return 0 + saved_id="$(state_value deploymentId)" + saved_project="$(state_value projectName)" + saved_branch="$(state_value sourceBranch)" + [[ "${saved_id}" == "${DEPLOYMENT_ID}" ]] || fail "状态文件 deploymentId 不匹配。" + [[ "${saved_project}" == "${PROJECT_NAME}" ]] || fail "状态文件 compose project 不匹配。" + if [[ -n "${SOURCE_BRANCH}" && "${saved_branch}" != "${SOURCE_BRANCH}" ]]; then + fail "部署实例属于分支 ${saved_branch},不能用 ${SOURCE_BRANCH} 操作。" + fi + if [[ -z "${SOURCE_BRANCH}" ]]; then + SOURCE_BRANCH="${saved_branch}" + fi +} + +write_state() { + local phase="$1" + local active="$2" + local source_commit="$3" + local web_port="$4" + local spacetime_port="$5" + local otlp_grpc_port="$6" + local otlp_http_port="$7" + local temporary_file="${STATE_FILE}.tmp.$$" + node - "${temporary_file}" "${DEPLOYMENT_ID}" "${PROJECT_NAME}" "${SOURCE_BRANCH}" \ + "${COMMIT_HASH}" "${source_commit}" "${phase}" "${active}" "${web_port}" "${spacetime_port}" \ + "${otlp_grpc_port}" "${otlp_http_port}" <<'NODE' +const fs = require('node:fs'); +const [file, deploymentId, projectName, sourceBranch, requestedCommit, sourceCommit, phase, active, + webPort, spacetimePort, otlpGrpcPort, otlpHttpPort] = process.argv.slice(2); +const number = (value) => value ? Number(value) : null; +fs.writeFileSync(file, `${JSON.stringify({ + schemaVersion: 1, + deploymentId, + projectName, + sourceBranch, + requestedCommit: requestedCommit || null, + sourceCommit: sourceCommit || null, + phase, + active: active === 'true', + webPort: number(webPort), + spacetimePort: number(spacetimePort), + otlpGrpcPort: number(otlpGrpcPort), + otlpHttpPort: number(otlpHttpPort), + updatedAt: new Date().toISOString(), +}, null, 2)}\n`, {mode: 0o600}); +NODE + mv -f "${temporary_file}" "${STATE_FILE}" + chmod 0600 "${STATE_FILE}" +} + +render_result() { + local phase="${1:-}" + local message="${2:-}" + local args=( + "${SCRIPT_ROOT}/preview-deployment-status.mjs" + --action "${ACTION}" + --deployment-id "${DEPLOYMENT_ID}" + --project-name "${PROJECT_NAME}" + --source-branch "${SOURCE_BRANCH}" + --state-file "${STATE_FILE}" + --output "${RESULT_FILE}" + --description-file "${DESCRIPTION_FILE}" + --web-host "$(resolve_web_host)" + ) + if [[ -n "${phase}" ]]; then + args+=(--phase "${phase}") + fi + if [[ -n "${message}" ]]; then + args+=(--message "${message}") + fi + node "${args[@]}" +} + +resolve_web_host() { + local route_source + if [[ -n "${WEB_HOST}" ]]; then + printf '%s\n' "${WEB_HOST}" + return + fi + route_source="$(ip -4 route get 1.1.1.1 2>/dev/null | sed -n 's/.* src \([^ ]*\).*/\1/p' | head -n 1)" + printf '%s\n' "${route_source:-127.0.0.1}" +} + +on_exit() { + local exit_code=$? + if [[ "${SCRIPT_FAILED}" == "1" && -n "${STATE_FILE}" ]]; then + set +e + if [[ "${ACTION}" == "DEPLOY" && -f "${STATE_FILE}" ]]; then + write_state "FAILED" "false" "$(state_value sourceCommit)" \ + "$(state_value webPort)" "$(state_value spacetimePort)" \ + "$(state_value otlpGrpcPort)" "$(state_value otlpHttpPort)" + fi + render_result "FAILED" "预览部署执行失败,请查看 Jenkins 构建日志。" + fi + exit "${exit_code}" +} +trap on_exit EXIT + +port_used_by_other_state() { + local key="$1" + local port="$2" + node - "${STATE_ROOT}" "${DEPLOYMENT_ID}" "${key}" "${port}" <<'NODE' +const fs = require('node:fs'); +const path = require('node:path'); +const [root, currentId, key, expected] = process.argv.slice(2); +let used = false; +for (const entry of fs.readdirSync(root, {withFileTypes: true})) { + if (!entry.isDirectory() || entry.name === currentId) continue; + try { + const state = JSON.parse(fs.readFileSync(path.join(root, entry.name, 'state.json'), 'utf8')); + if (state.active === true && String(state[key]) === expected) used = true; + } catch {} +} +process.exit(used ? 0 : 1); +NODE +} + +port_is_listening() { + local port="$1" + ss -H -ltn "sport = :${port}" | grep -q . +} + +allocate_port() { + local key="$1" + local start="$2" + local end="$3" + local preferred="${4:-}" + local port + if [[ -n "${preferred}" ]] && ! port_used_by_other_state "${key}" "${preferred}" && ! port_is_listening "${preferred}"; then + printf '%s\n' "${preferred}" + return + fi + for ((port = start; port <= end; port += 1)); do + if ! port_used_by_other_state "${key}" "${port}" && ! port_is_listening "${port}"; then + printf '%s\n' "${port}" + return + fi + done + fail "端口范围 ${start}-${end} 已无可用端口。" +} + +remove_project_resources() { + local ids=() + local images=() + local volumes=() + local networks=() + [[ "${PROJECT_NAME}" =~ ^genarrative-preview-[0-9a-f]{16}$ ]] || fail "拒绝操作非白名单 compose project: ${PROJECT_NAME}" + mapfile -t ids < <(docker ps -aq --filter "label=com.docker.compose.project=${PROJECT_NAME}") + if [[ "${#ids[@]}" -gt 0 ]]; then + docker rm -f "${ids[@]}" + fi + mapfile -t volumes < <(docker volume ls -q --filter "label=com.docker.compose.project=${PROJECT_NAME}") + if [[ "${#volumes[@]}" -gt 0 ]]; then + docker volume rm "${volumes[@]}" + fi + mapfile -t networks < <(docker network ls -q --filter "label=com.docker.compose.project=${PROJECT_NAME}") + if [[ "${#networks[@]}" -gt 0 ]]; then + docker network rm "${networks[@]}" + fi + mapfile -t images < <(docker image ls -q --filter "label=com.docker.compose.project=${PROJECT_NAME}") + if [[ "${#images[@]}" -gt 0 ]]; then + docker image rm "${images[@]}" + fi +} + +compose() { + env \ + COMPOSE_PROJECT_NAME="${PROJECT_NAME}" \ + GENARRATIVE_PREVIEW_SOURCE_DIR="${SOURCE_DIR}" \ + GENARRATIVE_PREVIEW_CONTROLLER_ROOT="${SCRIPT_ROOT}/.." \ + GENARRATIVE_CONTAINER_API_ENV_FILE="${STATE_DIR}/api-server.env" \ + GENARRATIVE_CONTAINER_HTTP_PORT="${WEB_PORT}" \ + GENARRATIVE_CONTAINER_SPACETIME_PORT="${SPACETIME_PORT}" \ + GENARRATIVE_CONTAINER_OTLP_GRPC_PORT="${OTLP_GRPC_PORT}" \ + GENARRATIVE_CONTAINER_OTLP_HTTP_PORT="${OTLP_HTTP_PORT}" \ + docker compose \ + -f "${SCRIPT_ROOT}/../deploy/container/docker-compose.loadtest.yml" \ + -f "${STATE_DIR}/docker-compose.preview.yml" \ + "$@" +} + +write_compose_override() { + local override_file="${STATE_DIR}/docker-compose.preview.yml" + cat >"${override_file}" <<'YAML' +services: + api-server: + build: + context: ${GENARRATIVE_PREVIEW_SOURCE_DIR} + dockerfile: ${GENARRATIVE_PREVIEW_CONTROLLER_ROOT}/deploy/container/api-server.Dockerfile + external-generation-worker: + build: + context: ${GENARRATIVE_PREVIEW_SOURCE_DIR} + dockerfile: ${GENARRATIVE_PREVIEW_CONTROLLER_ROOT}/deploy/container/api-server.Dockerfile + nginx: + build: + context: ${GENARRATIVE_PREVIEW_SOURCE_DIR} + dockerfile: ${GENARRATIVE_PREVIEW_CONTROLLER_ROOT}/deploy/container/api-server.Dockerfile + spacetimedb: + ports: !reset [] + otelcol: + ports: !reset [] +YAML + chmod 0600 "${override_file}" +} + +wait_for_url() { + local url="$1" + local attempts="${2:-60}" + local attempt + for ((attempt = 1; attempt <= attempts; attempt += 1)); do + if curl --fail --silent --show-error --max-time 3 "${url}" >/dev/null 2>&1; then + return 0 + fi + sleep 2 + done + return 1 +} + +prepare_env() { + local env_file="${STATE_DIR}/api-server.env" + local internal_secret jwt_secret + cp "${SOURCE_DIR}/deploy/container/api-server.env.example" "${env_file}" + chmod 0600 "${env_file}" + internal_secret="$(openssl rand -hex 32)" + jwt_secret="$(openssl rand -hex 32)" + node - "${env_file}" "${internal_secret}" "${jwt_secret}" <<'NODE' +const fs = require('node:fs'); +const [file, internalSecret, jwtSecret] = process.argv.slice(2); +let content = fs.readFileSync(file, 'utf8'); +content = content.replace(/^GENARRATIVE_INTERNAL_API_SECRET=.*$/mu, `GENARRATIVE_INTERNAL_API_SECRET=${internalSecret}`); +content = content.replace(/^GENARRATIVE_JWT_SECRET=.*$/mu, `GENARRATIVE_JWT_SECRET=${jwtSecret}`); +fs.writeFileSync(file, content, {encoding: 'utf8', mode: 0o600}); +NODE + unset internal_secret jwt_secret +} + +configure_spacetime_identity() { + local identity_payload spacetime_identity spacetime_token runtime_secret bootstrap_hash env_file + env_file="${STATE_DIR}/api-server.env" + identity_payload="$(curl --fail --silent --show-error --request POST \ + --header 'Accept: application/json' --header 'Content-Type: application/json' \ + "${SPACETIME_PUBLISH_URL}/v1/identity")" + spacetime_identity="$(printf '%s' "${identity_payload}" | node -e ' + let body=""; process.stdin.on("data", chunk => body += chunk); process.stdin.on("end", () => { + const data=JSON.parse(body); process.stdout.write(data.identity || data.Identity || data.identity_hex || data.identityHex || ""); + }); + ')" + spacetime_token="$(printf '%s' "${identity_payload}" | node -e ' + let body=""; process.stdin.on("data", chunk => body += chunk); process.stdin.on("end", () => { + const data=JSON.parse(body); process.stdout.write(data.token || data.Token || ""); + }); + ')" + [[ -n "${spacetime_identity}" && -n "${spacetime_token}" ]] || fail "SpacetimeDB identity 响应缺少 identity 或 token。" + runtime_secret="$(openssl rand -hex 32)" + bootstrap_hash="$(printf '%s' "${runtime_secret}" | sha256sum)" + bootstrap_hash="${bootstrap_hash%% *}" + { + printf '\nGENARRATIVE_SPACETIME_TOKEN=%s\n' "${spacetime_token}" + printf 'GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET=%s\n' "${runtime_secret}" + } >>"${env_file}" + printf '%s\n' "${bootstrap_hash}" >"${STATE_DIR}/bootstrap-secret.sha256" + chmod 0600 "${env_file}" "${STATE_DIR}/bootstrap-secret.sha256" + spacetime --config-path "${STATE_DIR}/spacetime-cli.toml" login --token "${spacetime_token}" >/dev/null + chmod 0600 "${STATE_DIR}/spacetime-cli.toml" + printf '[preview-deployer] SpacetimeDB identity: %s...\n' "${spacetime_identity:0:12}" + unset identity_payload spacetime_token runtime_secret bootstrap_hash +} + +deploy() { + local saved_web source_commit + [[ -d "${SOURCE_DIR}/.git" ]] || fail "缺少已经校验的源码 checkout: ${SOURCE_DIR}" + [[ -f "${SOURCE_DIR}/deploy/container/docker-compose.loadtest.yml" ]] || fail "目标提交缺少容器 compose 文件。" + [[ -f "${SOURCE_DIR}/deploy/container/api-server.env.example" ]] || fail "目标提交缺少容器 env 示例。" + source_commit="$(git -C "${SOURCE_DIR}" rev-parse --verify HEAD^{commit})" + if [[ -n "${COMMIT_HASH}" ]]; then + local requested_commit + requested_commit="$(git -C "${SOURCE_DIR}" rev-parse --verify "${COMMIT_HASH}^{commit}")" + [[ "${source_commit}" == "${requested_commit}" ]] || fail "源码 checkout 与 COMMIT_HASH 不一致。" + fi + + saved_web="$(state_value webPort)" + WEB_PORT="${saved_web:-8400}" + SPACETIME_PORT="" + OTLP_GRPC_PORT="" + OTLP_HTTP_PORT="" + + prepare_env + write_compose_override + compose config --quiet + compose build + remove_project_resources + WEB_PORT="$(allocate_port webPort 8400 8499 "${saved_web}")" + write_state "BUILDING" "false" "${source_commit}" "${WEB_PORT}" "${SPACETIME_PORT}" "${OTLP_GRPC_PORT}" "${OTLP_HTTP_PORT}" + compose up -d spacetimedb otelcol + local spacetime_container_id spacetime_ip + spacetime_container_id="$(compose ps -q spacetimedb)" + [[ -n "${spacetime_container_id}" ]] || fail "未找到 SpacetimeDB 容器。" + spacetime_ip="$(docker inspect --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "${spacetime_container_id}")" + [[ -n "${spacetime_ip}" ]] || fail "未取得 SpacetimeDB 容器地址。" + SPACETIME_PUBLISH_URL="http://${spacetime_ip}:3101" + wait_for_url "${SPACETIME_PUBLISH_URL}/v1/ping" || fail "SpacetimeDB 未在超时内就绪。" + configure_spacetime_identity + + ( + cd "${SOURCE_DIR}" + # shellcheck disable=SC1091 + source scripts/jenkins-prepare-toolchain-env.sh + export GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256 + GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256="$(cat "${STATE_DIR}/bootstrap-secret.sha256")" + spacetime --config-path "${STATE_DIR}/spacetime-cli.toml" \ + publish genarrative-loadtest \ + --server "${SPACETIME_PUBLISH_URL}" \ + --module-path server-rs/crates/spacetime-module \ + --yes=migrate,break-clients \ + --build-options="--debug" \ + --no-config + ) + + compose up -d + wait_for_url "http://127.0.0.1:${WEB_PORT}/" || fail "Web 未在超时内就绪。" + write_state "RUNNING" "true" "${source_commit}" "${WEB_PORT}" "${SPACETIME_PORT}" "${OTLP_GRPC_PORT}" "${OTLP_HTTP_PORT}" + local health_attempt + for ((health_attempt = 1; health_attempt <= 30; health_attempt += 1)); do + render_result + if node -e ' + const result = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")); + if (result.healthStatus !== "HEALTHY") process.exit(1); + ' "${RESULT_FILE}"; then + return + fi + sleep 2 + done + fail "容器健康检查未全部通过。" +} + +uninstall() { + [[ -f "${STATE_FILE}" && ! -L "${STATE_FILE}" ]] || fail "未找到可卸载的部署状态: ${DEPLOYMENT_ID}" + local source_commit web_port spacetime_port otlp_grpc_port otlp_http_port + source_commit="$(state_value sourceCommit)" + web_port="$(state_value webPort)" + spacetime_port="$(state_value spacetimePort)" + otlp_grpc_port="$(state_value otlpGrpcPort)" + otlp_http_port="$(state_value otlpHttpPort)" + remove_project_resources + write_state "UNINSTALLED" "false" "${source_commit}" "${web_port}" "${spacetime_port}" "${otlp_grpc_port}" "${otlp_http_port}" + render_result "UNINSTALLED" +} + +status() { + if [[ ! -f "${STATE_FILE}" ]]; then + render_result "NOT_FOUND" + return + fi + render_result +} + +validate_request +SCRIPT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +resolve_identity +ensure_safe_state_root +validate_existing_state + +exec 9>"${LOCK_FILE}" +flock -x 9 + +case "${ACTION}" in + DEPLOY) deploy ;; + UNINSTALL) uninstall ;; + STATUS) status ;; +esac + +SCRIPT_FAILED=0 +trap - EXIT diff --git a/scripts/preview-deployment-status.mjs b/scripts/preview-deployment-status.mjs new file mode 100644 index 000000000..19a09aa02 --- /dev/null +++ b/scripts/preview-deployment-status.mjs @@ -0,0 +1,297 @@ +import { execFileSync } from 'node:child_process'; +import { readFileSync, renameSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; + +const input = parseArgs(process.argv.slice(2)); +const state = readState(input.stateFile); +const deploymentId = input.deploymentId || state?.deploymentId || ''; +const projectName = input.projectName || state?.projectName || ''; +const action = input.action || 'STATUS'; +const phaseOverride = input.phase || ''; +const containers = projectName ? inspectProjectContainers(projectName) : []; +const serviceMap = new Map( + containers.map((container) => [container.service, container]), +); +const webPort = numberOrNull(state?.webPort); +const webHost = input.webHost || '127.0.0.1'; +const webUrl = webPort ? `http://${webHost}:${webPort}` : null; + +const probes = { + web: await probe(webPort ? `http://127.0.0.1:${webPort}/` : null), + spacetime: probeSpacetimeContainer(serviceMap.get('spacetimedb')), +}; + +const requiredServices = [ + 'spacetimedb', + 'api-server', + 'external-generation-worker', + 'nginx', + 'otelcol', +]; +const services = requiredServices.map((service) => { + const container = serviceMap.get(service); + return ( + container || { + service, + containerId: null, + state: 'missing', + health: 'missing', + } + ); +}); +const containersHealthy = services.every( + (service) => + service.state === 'running' && + (service.health === 'healthy' || service.health === 'none'), +); +const probesHealthy = probes.web.ok && probes.spacetime.ok; +const active = Boolean(state?.active); + +let phase = phaseOverride || state?.phase || 'NOT_FOUND'; +let healthStatus = 'UNKNOWN'; +if (!state) { + phase = phaseOverride || 'NOT_FOUND'; + healthStatus = phase === 'FAILED' ? 'UNHEALTHY' : 'NOT_FOUND'; +} else if (phase === 'UNINSTALLED' || action === 'UNINSTALL') { + phase = 'UNINSTALLED'; + healthStatus = 'UNINSTALLED'; +} else if (phase === 'FAILED') { + healthStatus = 'UNHEALTHY'; +} else if (active && containersHealthy && probesHealthy) { + phase = 'RUNNING'; + healthStatus = 'HEALTHY'; +} else if (active || containers.length > 0) { + phase = 'UNHEALTHY'; + healthStatus = 'UNHEALTHY'; +} + +const result = { + schemaVersion: 1, + action, + deploymentId, + projectName: projectName || null, + branch: state?.sourceBranch || input.sourceBranch || null, + requestedCommit: state?.requestedCommit || null, + resolvedCommit: state?.sourceCommit || null, + status: publicStatus(phase), + health: publicHealth(healthStatus), + sourceBranch: state?.sourceBranch || input.sourceBranch || null, + sourceCommit: state?.sourceCommit || null, + phase, + healthStatus, + active: phase === 'RUNNING' || phase === 'UNHEALTHY', + webPort, + webUrl, + ports: { + web: webPort, + spacetime: null, + otlpGrpc: null, + otlpHttp: null, + }, + probes, + services, + message: input.message || defaultMessage(phase), + updatedAt: new Date().toISOString(), +}; + +writeJsonAtomic(input.output, result); +if (input.descriptionFile) { + const description = [ + deploymentId || 'unknown', + result.sourceBranch || '-', + phase, + webUrl || '-', + ].join(' | '); + writeFileSync(input.descriptionFile, `${description}\n`, 'utf8'); +} +process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + +function parseArgs(args) { + const parsed = {}; + for (let index = 0; index < args.length; index += 1) { + const key = args[index]; + if (!key.startsWith('--')) { + throw new Error(`未知参数: ${key}`); + } + const value = args[index + 1]; + if (value === undefined || value.startsWith('--')) { + throw new Error(`参数缺少值: ${key}`); + } + parsed[toCamelCase(key.slice(2))] = value; + index += 1; + } + if (!parsed.output) { + throw new Error('必须提供 --output。'); + } + return parsed; +} + +function toCamelCase(value) { + return value.replace(/-([a-z])/gu, (_match, character) => + character.toUpperCase(), + ); +} + +function readState(filePath) { + if (!filePath) { + return null; + } + try { + return JSON.parse(readFileSync(filePath, 'utf8')); + } catch (error) { + if (error?.code === 'ENOENT') { + return null; + } + throw error; + } +} + +function inspectProjectContainers(projectName) { + let lines = ''; + try { + lines = execFileSync( + 'docker', + [ + 'ps', + '-a', + '--filter', + `label=com.docker.compose.project=${projectName}`, + '--format', + '{{.ID}}|{{.Label "com.docker.compose.service"}}|{{.State}}', + ], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }, + ); + } catch { + return []; + } + + return lines + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const [containerId, service, state] = line.split('|'); + return { + service, + containerId, + state, + health: inspectHealth(containerId), + }; + }); +} + +function inspectHealth(containerId) { + try { + const value = execFileSync( + 'docker', + [ + 'inspect', + '--format', + '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}', + containerId, + ], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }, + ).trim(); + return value || 'none'; + } catch { + return 'unknown'; + } +} + +async function probe(url) { + if (!url) { + return { ok: false, status: null, elapsedMs: null }; + } + const startedAt = Date.now(); + try { + const response = await fetch(url, { signal: AbortSignal.timeout(3_000) }); + await response.body?.cancel(); + return { + ok: response.ok, + status: response.status, + elapsedMs: Date.now() - startedAt, + }; + } catch { + return { ok: false, status: null, elapsedMs: Date.now() - startedAt }; + } +} + +function probeSpacetimeContainer(container) { + if (!container?.containerId || container.state !== 'running') { + return { ok: false, status: null, elapsedMs: null }; + } + const startedAt = Date.now(); + try { + execFileSync( + 'docker', + [ + 'exec', + container.containerId, + 'spacetime', + 'server', + 'ping', + 'http://127.0.0.1:3101', + ], + { stdio: 'ignore', timeout: 3_000 }, + ); + return { ok: true, status: 200, elapsedMs: Date.now() - startedAt }; + } catch { + return { ok: false, status: null, elapsedMs: Date.now() - startedAt }; + } +} + +function numberOrNull(value) { + const number = Number(value); + return Number.isInteger(number) && number > 0 ? number : null; +} + +function defaultMessage(phase) { + switch (phase) { + case 'RUNNING': + return '预览容器运行正常。'; + case 'UNHEALTHY': + return '预览容器已启动,但健康检查未全部通过。'; + case 'UNINSTALLED': + return '预览容器已卸载。'; + case 'NOT_FOUND': + return '未找到对应的预览部署。'; + case 'FAILED': + return '预览部署执行失败,请查看 Jenkins 构建日志。'; + default: + return `预览部署状态:${phase}`; + } +} + +function publicStatus(phase) { + switch (phase) { + case 'RUNNING': + case 'UNHEALTHY': + return 'running'; + case 'UNINSTALLED': + case 'NOT_FOUND': + return 'stopped'; + default: + return 'failed'; + } +} + +function publicHealth(healthStatus) { + switch (healthStatus) { + case 'HEALTHY': + return 'healthy'; + case 'UNHEALTHY': + return 'unhealthy'; + default: + return 'unknown'; + } +} + +function writeJsonAtomic(filePath, value) { + const absolutePath = path.resolve(filePath); + const temporaryPath = `${absolutePath}.tmp-${process.pid}`; + writeFileSync(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { + encoding: 'utf8', + mode: 0o600, + }); + renameSync(temporaryPath, absolutePath); +} diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index 3c82d1717..c5cea821e 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -2233,6 +2233,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" @@ -4314,6 +4320,25 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "preview-deployer-server" +version = "0.1.0" +dependencies = [ + "axum", + "http-body-util", + "reqwest", + "serde", + "serde_json", + "sha2", + "tokio", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "url", + "uuid", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -6317,10 +6342,19 @@ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags 2.13.0", "bytes", + "futures-core", "futures-util", "http", "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index 1274350bf..4684a8528 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -48,6 +48,7 @@ members = [ "crates/platform-speech", "crates/platform-editor-agent", "crates/pingora-gateway", + "crates/preview-deployer-server", "crates/server-manager-panel", "crates/shared-contracts", "crates/shared-kernel", diff --git a/server-rs/crates/preview-deployer-server/Cargo.toml b/server-rs/crates/preview-deployer-server/Cargo.toml new file mode 100644 index 000000000..ed2e8ba3c --- /dev/null +++ b/server-rs/crates/preview-deployer-server/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "preview-deployer-server" +edition.workspace = true +version.workspace = true +license.workspace = true + +[dependencies] +axum = { workspace = true } +reqwest = { workspace = true, features = ["json", "rustls-tls"] } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "time", "sync", "signal"] } +tower-http = { workspace = true, features = ["fs", "trace"] } +tracing = { workspace = true } +tracing-subscriber = { workspace = true, features = ["env-filter"] } +url = { workspace = true } +uuid = { workspace = true, features = ["v4"] } + +[dev-dependencies] +http-body-util = { workspace = true } +tower = { workspace = true, features = ["util"] } diff --git a/server-rs/crates/preview-deployer-server/src/config.rs b/server-rs/crates/preview-deployer-server/src/config.rs new file mode 100644 index 000000000..68fae583d --- /dev/null +++ b/server-rs/crates/preview-deployer-server/src/config.rs @@ -0,0 +1,177 @@ +use std::{env, fmt, path::PathBuf, time::Duration}; + +use url::Url; + +const JOB_PATH: &str = "job/shared/job/Genarrative-Preview-Deployer/"; + +#[derive(Clone)] +pub struct Config { + pub bind_address: String, + pub jenkins_root_url: Url, + pub jenkins_base_url: Url, + pub jenkins_username: String, + pub jenkins_api_token: String, + pub access_token: String, + pub allowed_hosts: Vec, + pub allowed_origins: Vec, + pub preview_web_host: String, + pub secure_cookie: bool, + pub static_dir: Option, + pub state_file: PathBuf, + pub poll_interval: Duration, +} + +impl fmt::Debug for Config { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("Config") + .field("bind_address", &self.bind_address) + .field("jenkins_root_url", &self.jenkins_root_url) + .field("jenkins_base_url", &self.jenkins_base_url) + .field( + "jenkins_username_configured", + &!self.jenkins_username.is_empty(), + ) + .field( + "jenkins_api_token_configured", + &!self.jenkins_api_token.is_empty(), + ) + .field("access_token_configured", &!self.access_token.is_empty()) + .field("allowed_hosts", &self.allowed_hosts) + .field("allowed_origins", &self.allowed_origins) + .field("preview_web_host", &self.preview_web_host) + .field("secure_cookie", &self.secure_cookie) + .field("static_dir", &self.static_dir) + .field("state_file", &self.state_file) + .field("poll_interval", &self.poll_interval) + .finish() + } +} + +impl Config { + pub fn from_env() -> Result { + let bind_address = required("GENARRATIVE_PREVIEW_DEPLOYER_BIND")?; + let mut jenkins_root_url = Url::parse(&required( + "GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_BASE_URL", + )?) + .map_err(|_| "GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_BASE_URL 不是有效 URL".to_string())?; + if !matches!(jenkins_root_url.scheme(), "http" | "https") + || jenkins_root_url.host_str().is_none() + { + return Err("Jenkins base URL 只允许 http/https 绝对 URL".to_string()); + } + if !jenkins_root_url.username().is_empty() || jenkins_root_url.password().is_some() { + return Err("Jenkins base URL 不能包含用户名或密码".to_string()); + } + jenkins_root_url.set_query(None); + jenkins_root_url.set_fragment(None); + if !jenkins_root_url.path().ends_with('/') { + let path = format!("{}/", jenkins_root_url.path()); + jenkins_root_url.set_path(&path); + } + let jenkins_base_url = jenkins_root_url + .join(JOB_PATH) + .map_err(|_| "无法构造固定 Jenkins Job URL".to_string())?; + + let access_token = required("GENARRATIVE_PREVIEW_DEPLOYER_ACCESS_TOKEN")?; + if access_token.len() < 24 { + return Err("GENARRATIVE_PREVIEW_DEPLOYER_ACCESS_TOKEN 至少需要 24 个字符".to_string()); + } + let allowed_hosts = csv_required("GENARRATIVE_PREVIEW_DEPLOYER_ALLOWED_HOSTS")?; + let allowed_origins = csv_required("GENARRATIVE_PREVIEW_DEPLOYER_ALLOWED_ORIGINS")?; + let preview_web_host = required("GENARRATIVE_PREVIEW_DEPLOYER_WEB_HOST")?; + if preview_web_host.contains(['/', ':', '@']) { + return Err( + "GENARRATIVE_PREVIEW_DEPLOYER_WEB_HOST 只能填写不带协议和端口的主机名或 IP" + .to_string(), + ); + } + for origin in &allowed_origins { + let parsed = + Url::parse(origin).map_err(|_| format!("无效 allowed origin: {origin}"))?; + if !matches!(parsed.scheme(), "http" | "https") + || parsed.host_str().is_none() + || parsed.path() != "/" + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return Err(format!( + "allowed origin 必须是无路径的 http/https 源: {origin}" + )); + } + } + + let secure_cookie = env::var("GENARRATIVE_PREVIEW_DEPLOYER_SECURE_COOKIE") + .map(|value| value != "false") + .unwrap_or_else(|_| { + allowed_origins + .iter() + .all(|origin| origin.starts_with("https://")) + }); + let static_dir = env::var_os("GENARRATIVE_PREVIEW_DEPLOYER_STATIC_DIR") + .filter(|value| !value.is_empty()) + .map(PathBuf::from); + let state_file = PathBuf::from(required("GENARRATIVE_PREVIEW_DEPLOYER_STATE_FILE")?); + validate_state_file(&state_file)?; + + Ok(Self { + bind_address, + jenkins_root_url, + jenkins_base_url, + jenkins_username: required("GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_USERNAME")?, + jenkins_api_token: required("GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_API_TOKEN")?, + access_token, + allowed_hosts, + allowed_origins, + preview_web_host, + secure_cookie, + static_dir, + state_file, + poll_interval: Duration::from_secs(2), + }) + } +} + +fn validate_state_file(path: &std::path::Path) -> Result<(), String> { + if !path.is_absolute() || path == std::path::Path::new("/") || path.file_name().is_none() { + return Err( + "GENARRATIVE_PREVIEW_DEPLOYER_STATE_FILE 必须是非根目录绝对文件路径".to_string(), + ); + } + let parent = path + .parent() + .ok_or_else(|| "状态文件缺少父目录".to_string())?; + let metadata = std::fs::symlink_metadata(parent) + .map_err(|_| "预览部署状态文件父目录必须已存在".to_string())?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err("预览部署状态文件父目录必须是普通目录且不能是符号链接".to_string()); + } + if let Ok(metadata) = std::fs::symlink_metadata(path) { + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Err("预览部署状态文件必须是普通文件且不能是符号链接".to_string()); + } + } + Ok(()) +} + +fn required(name: &str) -> Result { + env::var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| format!("缺少必需环境变量 {name}")) +} + +fn csv_required(name: &str) -> Result, String> { + let values: Vec<_> = required(name)? + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .collect(); + if values.is_empty() { + Err(format!("{name} 至少需要一个值")) + } else { + Ok(values) + } +} diff --git a/server-rs/crates/preview-deployer-server/src/jenkins.rs b/server-rs/crates/preview-deployer-server/src/jenkins.rs new file mode 100644 index 000000000..fb4f45437 --- /dev/null +++ b/server-rs/crates/preview-deployer-server/src/jenkins.rs @@ -0,0 +1,297 @@ +use std::{future::Future, time::Duration}; + +use reqwest::{Client, StatusCode, header}; +use serde::Deserialize; +use url::Url; + +use crate::{Config, DeploymentStatus, HealthStatus}; + +#[derive(Clone)] +pub struct JenkinsClient { + http: Client, + root_url: Url, + job_url: Url, + username: String, + api_token: String, + poll_interval: Duration, +} + +pub enum BuildAction<'a> { + Deploy { + deployment_id: &'a str, + branch: &'a str, + commit_hash: Option<&'a str>, + }, + Uninstall { + deployment_id: &'a str, + branch: &'a str, + }, +} + +pub struct BuildReference { + queue_url: Url, +} + +impl BuildReference { + pub fn as_str(&self) -> &str { + self.queue_url.as_str() + } +} + +pub struct JenkinsOutcome { + pub success: bool, + pub cancelled: bool, + pub result: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PreviewResult { + pub schema_version: Option, + pub action: Option, + #[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_url: Option, + pub message: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Crumb { + crumb_request_field: String, + crumb: String, +} + +#[derive(Deserialize)] +struct QueueItem { + cancelled: Option, + executable: Option, +} + +#[derive(Deserialize)] +struct Executable { + url: String, +} + +#[derive(Deserialize)] +struct BuildState { + building: bool, + result: Option, +} + +impl JenkinsClient { + pub fn new(config: &Config) -> Result { + let http = Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(20)) + .build() + .map_err(|error| format!("无法创建 Jenkins HTTP client: {error}"))?; + Ok(Self { + http, + root_url: config.jenkins_root_url.clone(), + job_url: config.jenkins_base_url.clone(), + username: config.jenkins_username.clone(), + api_token: config.jenkins_api_token.clone(), + poll_interval: config.poll_interval, + }) + } + + pub async fn trigger(&self, action: BuildAction<'_>) -> Result { + let mut parameters = Vec::new(); + match action { + BuildAction::Deploy { + deployment_id, + branch, + commit_hash, + } => { + parameters.push(("ACTION", "DEPLOY")); + parameters.push(("DEPLOYMENT_ID", deployment_id)); + parameters.push(("SOURCE_BRANCH", branch)); + parameters.push(("COMMIT_HASH", commit_hash.unwrap_or(""))); + } + BuildAction::Uninstall { + deployment_id, + branch, + } => { + parameters.push(("ACTION", "UNINSTALL")); + parameters.push(("DEPLOYMENT_ID", deployment_id)); + parameters.push(("SOURCE_BRANCH", branch)); + parameters.push(("COMMIT_HASH", "")); + } + } + + let trigger_url = self + .job_url + .join("buildWithParameters") + .map_err(|error| error.to_string())?; + let mut request = self + .http + .post(trigger_url) + .basic_auth(&self.username, Some(&self.api_token)) + .form(¶meters); + if let Some((field, value)) = self.crumb().await? { + let field = header::HeaderName::from_bytes(field.as_bytes()) + .map_err(|_| "Jenkins crumb header 名称无效".to_string())?; + let value = header::HeaderValue::from_str(&value) + .map_err(|_| "Jenkins crumb header 值无效".to_string())?; + request = request.header(field, value); + } + let response = request + .send() + .await + .map_err(|error| format!("Jenkins trigger 请求失败: {error}"))?; + if !response.status().is_success() { + return Err(format!("Jenkins trigger 返回 HTTP {}", response.status())); + } + let location = response + .headers() + .get(header::LOCATION) + .and_then(|value| value.to_str().ok()) + .ok_or_else(|| "Jenkins trigger 响应缺少 queue Location".to_string())?; + let queue_url = self.resolve_trusted_url(location)?; + Ok(BuildReference { queue_url }) + } + + pub fn restore_reference(&self, value: &str) -> Result { + Ok(BuildReference { + queue_url: self.resolve_trusted_url(value)?, + }) + } + + async fn crumb(&self) -> Result, String> { + let crumb_url = self + .root_url + .join("crumbIssuer/api/json") + .map_err(|error| error.to_string())?; + let response = self + .http + .get(crumb_url) + .basic_auth(&self.username, Some(&self.api_token)) + .send() + .await + .map_err(|error| format!("Jenkins crumb 请求失败: {error}"))?; + if matches!( + response.status(), + StatusCode::NOT_FOUND | StatusCode::FORBIDDEN + ) { + return Ok(None); + } + if !response.status().is_success() { + return Err(format!("Jenkins crumb 返回 HTTP {}", response.status())); + } + let crumb: Crumb = response + .json() + .await + .map_err(|_| "Jenkins crumb 响应格式无效".to_string())?; + Ok(Some((crumb.crumb_request_field, crumb.crumb))) + } + + pub async fn wait_for_outcome( + &self, + reference: BuildReference, + mut on_build: F, + ) -> Result + where + F: FnMut(&str) -> Fut, + Fut: Future, + { + let build_url = loop { + let item_url = reference + .queue_url + .join("api/json") + .map_err(|error| error.to_string())?; + let item: QueueItem = self.get_json(item_url).await?; + if item.cancelled.unwrap_or(false) { + return Ok(JenkinsOutcome { + success: false, + cancelled: true, + result: None, + }); + } + if let Some(executable) = item.executable { + let url = self.resolve_trusted_url(&executable.url)?; + break url; + } + tokio::time::sleep(self.poll_interval).await; + }; + + on_build(build_url.as_str()).await; + let successful = loop { + let state_url = build_url + .join("api/json") + .map_err(|error| error.to_string())?; + let state: BuildState = self.get_json(state_url).await?; + if state.building { + tokio::time::sleep(self.poll_interval).await; + continue; + } + break state.result.as_deref() == Some("SUCCESS"); + }; + if !successful { + return Ok(JenkinsOutcome { + success: false, + cancelled: false, + result: None, + }); + } + + let artifact_url = build_url + .join("artifact/preview-result.json") + .map_err(|error| error.to_string())?; + let result = self.get_json(artifact_url).await?; + Ok(JenkinsOutcome { + success: true, + cancelled: false, + result: Some(result), + }) + } + + async fn get_json Deserialize<'de>>(&self, url: Url) -> Result { + self.ensure_same_origin(&url)?; + let response = self + .http + .get(url) + .basic_auth(&self.username, Some(&self.api_token)) + .send() + .await + .map_err(|error| format!("Jenkins 状态请求失败: {error}"))?; + if !response.status().is_success() { + return Err(format!("Jenkins 状态返回 HTTP {}", response.status())); + } + response + .json() + .await + .map_err(|_| "Jenkins 状态响应格式无效".to_string()) + } + + fn resolve_trusted_url(&self, value: &str) -> Result { + let url = Url::parse(value) + .or_else(|_| self.job_url.join(value)) + .map_err(|_| "Jenkins 返回了无效 URL".to_string())?; + self.ensure_same_origin(&url)?; + Ok(url) + } + + fn ensure_same_origin(&self, url: &Url) -> Result<(), String> { + if url.username().is_empty() + && url.password().is_none() + && url.scheme() == self.root_url.scheme() + && url.host_str() == self.root_url.host_str() + && url.port_or_known_default() == self.root_url.port_or_known_default() + && url.path().starts_with(self.root_url.path()) + { + Ok(()) + } else { + Err("Jenkins 返回了非受信源 URL".to_string()) + } + } +} diff --git a/server-rs/crates/preview-deployer-server/src/lib.rs b/server-rs/crates/preview-deployer-server/src/lib.rs new file mode 100644 index 000000000..4915763ee --- /dev/null +++ b/server-rs/crates/preview-deployer-server/src/lib.rs @@ -0,0 +1,1078 @@ +mod config; +mod jenkins; + +use std::{ + collections::HashMap, + sync::Arc, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use axum::extract::DefaultBodyLimit; +use axum::{ + Json, Router, + extract::{Path, Request, State}, + http::{HeaderMap, HeaderValue, StatusCode, header}, + middleware::{self, Next}, + response::{IntoResponse, Response}, + routing::{get, post}, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tokio::sync::RwLock; +use tower_http::{ + services::{ServeDir, ServeFile}, + trace::TraceLayer, +}; +use tracing::{error, warn}; +use uuid::Uuid; + +pub use config::Config; +use jenkins::{BuildAction, BuildReference, JenkinsClient, JenkinsOutcome}; + +const SESSION_COOKIE: &str = "genarrative_preview_session"; +const SESSION_TTL: Duration = Duration::from_secs(12 * 60 * 60); + +#[derive(Clone)] +pub struct AppState { + config: Arc, + jenkins: JenkinsClient, + deployments: Arc>>, + sessions: Arc>>, +} + +impl AppState { + pub fn new(config: Config) -> Result { + let jenkins = JenkinsClient::new(&config)?; + let deployments = load_deployments(&config)?; + Ok(Self { + config: Arc::new(config), + jenkins, + deployments: Arc::new(RwLock::new(deployments)), + sessions: Arc::new(RwLock::new(HashMap::new())), + }) + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum DeploymentStatus { + Queued, + Building, + Deploying, + Running, + Uninstalling, + Stopped, + Failed, + Cancelled, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum HealthStatus { + Pending, + Healthy, + Unhealthy, + Unknown, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Deployment { + pub id: String, + pub branch: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub commit_hash: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub resolved_commit: Option, + pub status: DeploymentStatus, + pub health: HealthStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub web_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub jenkins_build_url: Option, + pub created_at: u64, + pub updated_at: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, + pub can_uninstall: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct DeploymentRecord { + public: Deployment, + operation: Operation, + #[serde(default, skip_serializing_if = "Option::is_none")] + queue_url: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +enum Operation { + Deploy, + Uninstall, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PersistedState { + schema_version: u8, + deployments: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SessionRequest { + access_token: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct SessionResponse { + authenticated: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct DeployRequest { + branch: String, + commit_hash: Option, +} + +#[derive(Debug, Serialize)] +struct DeploymentList { + deployments: Vec, +} + +#[derive(Debug, Serialize)] +struct ErrorBody { + error: &'static str, + message: String, +} + +#[derive(Debug)] +struct ApiError { + status: StatusCode, + code: &'static str, + message: String, +} + +impl ApiError { + fn bad_request(message: impl Into) -> Self { + Self { + status: StatusCode::BAD_REQUEST, + code: "invalid_request", + message: message.into(), + } + } + + fn unauthorized() -> Self { + Self { + status: StatusCode::UNAUTHORIZED, + code: "authentication_required", + message: "请先登录预览部署控制台".to_string(), + } + } + + fn not_found() -> Self { + Self { + status: StatusCode::NOT_FOUND, + code: "deployment_not_found", + message: "预览实例不存在".to_string(), + } + } + + fn conflict(message: impl Into) -> Self { + Self { + status: StatusCode::CONFLICT, + code: "deployment_conflict", + message: message.into(), + } + } + + fn upstream(message: impl Into) -> Self { + Self { + status: StatusCode::BAD_GATEWAY, + code: "jenkins_unavailable", + message: message.into(), + } + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + ( + self.status, + Json(ErrorBody { + error: self.code, + message: self.message, + }), + ) + .into_response() + } +} + +pub fn build_router(state: AppState) -> Router { + resume_monitors(&state); + let api = Router::new() + .route("/healthz", get(healthz)) + .route( + "/api/preview-deployer/session", + post(login).get(session_status).delete(logout), + ) + .route( + "/api/preview-deployer/deployments", + get(list_deployments).post(create_deployment), + ) + .route( + "/api/preview-deployer/deployments/{id}", + get(get_deployment), + ) + .route( + "/api/preview-deployer/deployments/{id}/uninstall", + post(uninstall_deployment), + ); + + let mut router = Router::new().merge(api); + if let Some(static_dir) = state.config.static_dir.clone() { + router = router.fallback_service( + ServeDir::new(&static_dir) + .append_index_html_on_directories(true) + .fallback(ServeFile::new(static_dir.join("index.html"))), + ); + } + + router + .layer(DefaultBodyLimit::max(8 * 1024)) + .layer(TraceLayer::new_for_http()) + .layer(middleware::from_fn_with_state( + state.clone(), + enforce_request_boundary, + )) + .with_state(state) +} + +async fn healthz() -> StatusCode { + StatusCode::NO_CONTENT +} + +async fn enforce_request_boundary( + State(state): State, + request: Request, + next: Next, +) -> Result { + let headers = request.headers(); + let host = headers + .get(header::HOST) + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + if !state + .config + .allowed_hosts + .iter() + .any(|allowed| constant_time_eq(host.as_bytes(), allowed.as_bytes())) + { + return Err(ApiError { + status: StatusCode::FORBIDDEN, + code: "host_forbidden", + message: "请求 Host 不受信任".to_string(), + }); + } + + if request.uri().path().starts_with("/api/") && request.method() != axum::http::Method::GET { + let origin = headers + .get(header::ORIGIN) + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + if !state + .config + .allowed_origins + .iter() + .any(|allowed| constant_time_eq(origin.as_bytes(), allowed.as_bytes())) + { + return Err(ApiError { + status: StatusCode::FORBIDDEN, + code: "origin_forbidden", + message: "请求 Origin 不受信任".to_string(), + }); + } + } + + Ok(next.run(request).await) +} + +async fn login( + State(state): State, + Json(payload): Json, +) -> Result { + if !constant_time_eq( + payload.access_token.as_bytes(), + state.config.access_token.as_bytes(), + ) { + return Err(ApiError::unauthorized()); + } + + let session_id = Uuid::new_v4().simple().to_string(); + let expires_at = unix_now().saturating_add(SESSION_TTL.as_secs()); + state + .sessions + .write() + .await + .insert(hash_session(&session_id), expires_at); + let secure = if state.config.secure_cookie { + "; Secure" + } else { + "" + }; + let cookie = format!( + "{SESSION_COOKIE}={session_id}; Path=/; HttpOnly; SameSite=Strict; Max-Age={}{}", + SESSION_TTL.as_secs(), + secure + ); + let mut response = Json(SessionResponse { + authenticated: true, + }) + .into_response(); + response.headers_mut().insert( + header::SET_COOKIE, + HeaderValue::from_str(&cookie).map_err(|_| ApiError::bad_request("无法创建登录会话"))?, + ); + Ok(response) +} + +async fn session_status( + State(state): State, + headers: HeaderMap, +) -> Json { + Json(SessionResponse { + authenticated: authenticated(&state, &headers).await, + }) +} + +async fn logout(State(state): State, headers: HeaderMap) -> Response { + if let Some(session_id) = session_cookie(&headers) { + state + .sessions + .write() + .await + .remove(&hash_session(&session_id)); + } + let secure = if state.config.secure_cookie { + "; Secure" + } else { + "" + }; + let cookie = format!("{SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0{secure}"); + let mut response = StatusCode::NO_CONTENT.into_response(); + if let Ok(value) = HeaderValue::from_str(&cookie) { + response.headers_mut().insert(header::SET_COOKIE, value); + } + response +} + +async fn list_deployments( + State(state): State, + headers: HeaderMap, +) -> Result, ApiError> { + require_session(&state, &headers).await?; + refresh_running_health(&state).await; + let mut deployments: Vec<_> = state + .deployments + .read() + .await + .values() + .map(|record| record.public.clone()) + .collect(); + deployments.sort_by(|left, right| right.created_at.cmp(&left.created_at)); + Ok(Json(DeploymentList { deployments })) +} + +async fn get_deployment( + State(state): State, + headers: HeaderMap, + Path(id): Path, +) -> Result, ApiError> { + require_session(&state, &headers).await?; + validate_deployment_id(&id)?; + refresh_running_health(&state).await; + let deployment = state + .deployments + .read() + .await + .get(&id) + .map(|record| record.public.clone()) + .ok_or_else(ApiError::not_found)?; + Ok(Json(deployment)) +} + +async fn refresh_running_health(state: &AppState) { + let probes: Vec<_> = state + .deployments + .read() + .await + .iter() + .filter_map(|(id, record)| { + (record.public.status == DeploymentStatus::Running) + .then(|| record.public.web_url.clone().map(|url| (id.clone(), url))) + .flatten() + }) + .collect(); + if probes.is_empty() { + return; + } + + let http = match reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(3)) + .build() + { + Ok(http) => http, + Err(_) => return, + }; + let mut tasks = tokio::task::JoinSet::new(); + for (id, url) in probes { + let http = http.clone(); + tasks.spawn(async move { + let healthy = http + .get(url) + .send() + .await + .is_ok_and(|response| response.status().is_success()); + (id, healthy) + }); + } + + let mut changed = false; + while let Some(Ok((id, healthy))) = tasks.join_next().await { + let mut deployments = state.deployments.write().await; + let Some(record) = deployments.get_mut(&id) else { + continue; + }; + let next = if healthy { + HealthStatus::Healthy + } else { + HealthStatus::Unhealthy + }; + if record.public.status == DeploymentStatus::Running && record.public.health != next { + record.public.health = next; + record.public.updated_at = unix_now(); + record.public.message = Some(if healthy { + "预览实例运行正常".to_string() + } else { + "预览实例 Web 健康检查失败".to_string() + }); + changed = true; + } + } + if changed && let Err(cause) = persist_deployments(state).await { + error!(error = %cause, "failed to persist preview health refresh"); + } +} + +async fn create_deployment( + State(state): State, + headers: HeaderMap, + Json(payload): Json, +) -> Result<(StatusCode, Json), ApiError> { + require_session(&state, &headers).await?; + let branch = validate_branch(&payload.branch)?; + let commit_hash = payload + .commit_hash + .as_deref() + .map(validate_commit) + .transpose()?; + let id = derive_deployment_id(&branch); + let now = unix_now(); + let deployment = Deployment { + id: id.clone(), + branch: branch.clone(), + commit_hash: commit_hash.clone(), + resolved_commit: None, + status: DeploymentStatus::Queued, + health: HealthStatus::Pending, + web_url: None, + jenkins_build_url: None, + created_at: now, + updated_at: now, + message: Some("等待 Jenkins 调度".to_string()), + can_uninstall: false, + }; + { + let mut deployments = state.deployments.write().await; + if deployments.get(&id).is_some_and(|record| { + matches!( + record.public.status, + DeploymentStatus::Queued + | DeploymentStatus::Building + | DeploymentStatus::Deploying + | DeploymentStatus::Uninstalling + ) + }) { + return Err(ApiError::conflict("该分支已有进行中的部署操作")); + } + deployments.insert( + id.clone(), + DeploymentRecord { + public: deployment.clone(), + operation: Operation::Deploy, + queue_url: None, + }, + ); + } + persist_deployments(&state) + .await + .map_err(ApiError::upstream)?; + + let reference = match state + .jenkins + .trigger(BuildAction::Deploy { + deployment_id: &id, + branch: &branch, + commit_hash: commit_hash.as_deref(), + }) + .await + { + Ok(reference) => reference, + Err(cause) => { + mark_failed(&state, &id, "无法触发 Jenkins 构建").await; + warn!(deployment_id = %id, error = %cause, "failed to trigger preview deployment"); + return Err(ApiError::upstream("无法触发 Jenkins 构建")); + } + }; + record_queue_reference(&state, &id, &reference).await; + spawn_monitor(state, id, reference); + Ok((StatusCode::ACCEPTED, Json(deployment))) +} + +async fn uninstall_deployment( + State(state): State, + headers: HeaderMap, + Path(id): Path, +) -> Result<(StatusCode, Json), ApiError> { + require_session(&state, &headers).await?; + validate_deployment_id(&id)?; + let branch = { + let mut deployments = state.deployments.write().await; + let record = deployments.get_mut(&id).ok_or_else(ApiError::not_found)?; + if matches!( + record.public.status, + DeploymentStatus::Queued + | DeploymentStatus::Building + | DeploymentStatus::Deploying + | DeploymentStatus::Uninstalling + ) { + return Err(ApiError::conflict("该实例当前不能卸载")); + } + if !record.public.can_uninstall { + return Err(ApiError::conflict("该实例当前没有可卸载的容器")); + } + record.public.status = DeploymentStatus::Uninstalling; + record.public.health = HealthStatus::Pending; + record.public.can_uninstall = false; + record.public.updated_at = unix_now(); + record.public.message = Some("等待 Jenkins 卸载".to_string()); + record.operation = Operation::Uninstall; + record.queue_url = None; + record.public.branch.clone() + }; + persist_deployments(&state) + .await + .map_err(ApiError::upstream)?; + + let reference = match state + .jenkins + .trigger(BuildAction::Uninstall { + deployment_id: &id, + branch: &branch, + }) + .await + { + Ok(reference) => reference, + Err(cause) => { + let mut deployments = state.deployments.write().await; + if let Some(record) = deployments.get_mut(&id) { + record.public.status = DeploymentStatus::Failed; + record.public.health = HealthStatus::Unknown; + record.public.can_uninstall = true; + record.public.updated_at = unix_now(); + record.public.message = Some("无法触发 Jenkins 卸载".to_string()); + } + drop(deployments); + if let Err(error) = persist_deployments(&state).await { + error!(deployment_id = %id, %error, "failed to persist uninstall trigger failure"); + } + warn!(deployment_id = %id, error = %cause, "failed to trigger preview uninstall"); + return Err(ApiError::upstream("无法触发 Jenkins 卸载")); + } + }; + record_queue_reference(&state, &id, &reference).await; + let deployment = state + .deployments + .read() + .await + .get(&id) + .expect("deployment exists") + .public + .clone(); + spawn_monitor(state, id, reference); + Ok((StatusCode::ACCEPTED, Json(deployment))) +} + +fn spawn_monitor(state: AppState, id: String, reference: BuildReference) { + tokio::spawn(async move { + match tokio::time::timeout( + Duration::from_secs(6 * 60 * 60), + monitor_build(&state, &id, reference), + ) + .await + { + Ok(Ok(())) => {} + Ok(Err(cause)) => { + error!(deployment_id = %id, error = %cause, "preview Jenkins monitor failed"); + mark_failed(&state, &id, "Jenkins 构建状态不可用").await; + } + Err(_) => { + error!(deployment_id = %id, "preview Jenkins monitor timed out"); + mark_failed(&state, &id, "Jenkins 构建等待超时").await; + } + } + }); +} + +fn resume_monitors(state: &AppState) { + let state = state.clone(); + tokio::spawn(async move { + let pending: Vec<_> = state + .deployments + .read() + .await + .iter() + .filter_map(|(id, record)| { + matches!( + record.public.status, + DeploymentStatus::Queued + | DeploymentStatus::Building + | DeploymentStatus::Deploying + | DeploymentStatus::Uninstalling + ) + .then(|| (id.clone(), record.queue_url.clone())) + }) + .collect(); + for (id, queue_url) in pending { + let Some(queue_url) = queue_url else { + mark_failed(&state, &id, "服务重启后缺少 Jenkins 队列引用").await; + continue; + }; + match state.jenkins.restore_reference(&queue_url) { + Ok(reference) => spawn_monitor(state.clone(), id, reference), + Err(cause) => { + warn!(deployment_id = %id, error = %cause, "invalid persisted Jenkins queue reference"); + mark_failed(&state, &id, "Jenkins 队列引用无效").await; + } + } + } + }); +} + +async fn record_queue_reference(state: &AppState, id: &str, reference: &BuildReference) { + if let Some(record) = state.deployments.write().await.get_mut(id) { + record.queue_url = Some(reference.as_str().to_string()); + } + if let Err(error) = persist_deployments(state).await { + error!(deployment_id = %id, %error, "failed to persist Jenkins queue reference"); + } +} + +async fn monitor_build( + state: &AppState, + id: &str, + reference: BuildReference, +) -> Result<(), String> { + let outcome = state + .jenkins + .wait_for_outcome(reference, |build_url| { + let state = state.clone(); + let id = id.to_string(); + let build_url = build_url.to_string(); + async move { + let mut deployments = state.deployments.write().await; + if let Some(record) = deployments.get_mut(&id) { + record.public.status = match record.operation { + Operation::Deploy => DeploymentStatus::Building, + Operation::Uninstall => DeploymentStatus::Uninstalling, + }; + record.public.jenkins_build_url = Some(build_url); + record.public.updated_at = unix_now(); + record.public.message = Some(match record.operation { + Operation::Deploy => "Jenkins 正在构建".to_string(), + Operation::Uninstall => "Jenkins 正在卸载".to_string(), + }); + } + drop(deployments); + if let Err(cause) = persist_deployments(&state).await { + error!(deployment_id = %id, error = %cause, "failed to persist preview build transition"); + } + } + }) + .await?; + apply_outcome(state, id, outcome).await; + Ok(()) +} + +async fn apply_outcome(state: &AppState, id: &str, outcome: JenkinsOutcome) { + let mut deployments = state.deployments.write().await; + let Some(record) = deployments.get_mut(id) else { + return; + }; + record.public.updated_at = unix_now(); + if outcome.cancelled { + record.public.status = DeploymentStatus::Cancelled; + record.public.health = HealthStatus::Unknown; + record.public.can_uninstall = record.operation == Operation::Uninstall; + record.public.message = Some("Jenkins 构建已取消".to_string()); + } else if !outcome.success { + record.public.status = DeploymentStatus::Failed; + record.public.health = HealthStatus::Unknown; + record.public.can_uninstall = record.operation == Operation::Uninstall; + record.public.message = Some("Jenkins 构建失败".to_string()); + } else if let Some(result) = outcome.result { + let expected_action = match record.operation { + Operation::Deploy => "DEPLOY", + Operation::Uninstall => "UNINSTALL", + }; + let expected_project = format!("genarrative-{id}"); + if result.schema_version != Some(1) + || result.action.as_deref() != Some(expected_action) + || result.deployment_id.as_deref() != Some(id) + || result.project_name.as_deref() != Some(expected_project.as_str()) + || result.branch.as_deref() != Some(record.public.branch.as_str()) + { + record.public.status = DeploymentStatus::Failed; + record.public.health = HealthStatus::Unknown; + record.public.message = Some("Jenkins 返回了不匹配的部署结果".to_string()); + } else if result + .resolved_commit + .as_deref() + .is_some_and(|value| validate_commit(value).is_err()) + { + record.public.status = DeploymentStatus::Failed; + record.public.health = HealthStatus::Unknown; + record.public.message = Some("Jenkins 返回了无效 commit".to_string()); + } else if result + .web_url + .as_deref() + .is_some_and(|value| !is_safe_web_url(value, &state.config.preview_web_host)) + { + 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_url.is_none() + || result.phase.as_deref() != Some("RUNNING") + || result.health_status.as_deref() != Some("HEALTHY")) + { + record.public.status = DeploymentStatus::Failed; + record.public.health = HealthStatus::Unknown; + record.public.message = Some("Jenkins 部署结果缺少运行或健康证明".to_string()); + } else if record.operation == Operation::Uninstall + && (result.phase.as_deref() != Some("UNINSTALLED") + || result.health_status.as_deref() != Some("UNINSTALLED")) + { + record.public.status = DeploymentStatus::Failed; + record.public.health = HealthStatus::Unknown; + record.public.can_uninstall = true; + record.public.message = Some("Jenkins 卸载结果缺少完成证明".to_string()); + } else { + if let Some(value) = result.resolved_commit { + record.public.resolved_commit = Some(value.to_ascii_lowercase()); + } + if let Some(value) = result.web_url { + record.public.web_url = Some(value); + } + if let Some(value) = result.health { + record.public.health = value; + } + if let Some(value) = result.message { + record.public.message = Some(value); + } + if let Some(value) = result.status { + record.public.status = value; + } + if let Some(value) = result.phase.as_deref().and_then(map_artifact_status) { + record.public.status = value; + } + if let Some(value) = result + .health_status + .as_deref() + .and_then(map_artifact_health) + { + record.public.health = value; + } + } + } + + if outcome.success && !matches!(record.public.status, DeploymentStatus::Failed) { + match record.operation { + Operation::Deploy => { + if !matches!( + record.public.status, + DeploymentStatus::Running + | DeploymentStatus::Failed + | DeploymentStatus::Cancelled + ) { + record.public.status = DeploymentStatus::Running; + } + if record.public.health == HealthStatus::Pending { + record.public.health = HealthStatus::Unknown; + } + record.public.can_uninstall = record.public.status == DeploymentStatus::Running; + if record.public.message.is_none() { + record.public.message = Some("预览实例已发布".to_string()); + } + } + Operation::Uninstall => { + record.public.status = DeploymentStatus::Stopped; + record.public.health = HealthStatus::Unknown; + record.public.web_url = None; + record.public.can_uninstall = false; + if record.public.message.is_none() { + record.public.message = Some("预览实例已卸载".to_string()); + } + } + } + } + record.queue_url = None; + drop(deployments); + if let Err(cause) = persist_deployments(state).await { + error!(deployment_id = %id, error = %cause, "failed to persist preview outcome"); + } +} + +async fn mark_failed(state: &AppState, id: &str, message: &str) { + if let Some(record) = state.deployments.write().await.get_mut(id) { + record.public.status = DeploymentStatus::Failed; + record.public.health = HealthStatus::Unknown; + record.public.updated_at = unix_now(); + record.public.message = Some(message.to_string()); + } + if let Err(cause) = persist_deployments(state).await { + error!(deployment_id = %id, error = %cause, "failed to persist preview failure"); + } +} + +fn load_deployments(config: &Config) -> Result, String> { + let bytes = match std::fs::read(&config.state_file) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(HashMap::new()), + Err(error) => return Err(format!("无法读取预览部署状态文件: {error}")), + }; + let persisted: PersistedState = + serde_json::from_slice(&bytes).map_err(|_| "预览部署状态文件格式无效".to_string())?; + if persisted.schema_version != 1 { + return Err("预览部署状态文件 schemaVersion 不受支持".to_string()); + } + let mut deployments = HashMap::new(); + for record in persisted.deployments { + validate_deployment_id(&record.public.id) + .map_err(|_| "状态文件包含无效部署 ID".to_string())?; + let branch = validate_branch(&record.public.branch) + .map_err(|_| "状态文件包含无效分支名".to_string())?; + if derive_deployment_id(&branch) != record.public.id { + return Err("状态文件部署 ID 与分支不匹配".to_string()); + } + if let Some(commit) = record.public.commit_hash.as_deref() { + validate_commit(commit).map_err(|_| "状态文件包含无效 commit".to_string())?; + } + if record + .public + .web_url + .as_deref() + .is_some_and(|value| !is_safe_web_url(value, &config.preview_web_host)) + { + return Err("状态文件包含无效 Web 地址".to_string()); + } + if deployments + .insert(record.public.id.clone(), record) + .is_some() + { + return Err("状态文件包含重复部署 ID".to_string()); + } + } + Ok(deployments) +} + +async fn persist_deployments(state: &AppState) -> Result<(), String> { + let mut deployments: Vec<_> = state.deployments.read().await.values().cloned().collect(); + deployments.sort_by(|left, right| left.public.id.cmp(&right.public.id)); + let bytes = serde_json::to_vec_pretty(&PersistedState { + schema_version: 1, + deployments, + }) + .map_err(|_| "无法序列化预览部署状态".to_string())?; + let parent = state + .config + .state_file + .parent() + .ok_or_else(|| "状态文件缺少父目录".to_string())?; + let temp_path = parent.join(format!( + ".preview-deployer-state-{}.tmp", + Uuid::new_v4().simple() + )); + tokio::fs::write(&temp_path, bytes) + .await + .map_err(|error| format!("无法写入预览部署临时状态: {error}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + tokio::fs::set_permissions(&temp_path, std::fs::Permissions::from_mode(0o600)) + .await + .map_err(|error| format!("无法限制预览部署状态权限: {error}"))?; + } + if let Err(error) = tokio::fs::rename(&temp_path, &state.config.state_file).await { + let _ = tokio::fs::remove_file(&temp_path).await; + return Err(format!("无法原子安装预览部署状态: {error}")); + } + Ok(()) +} + +async fn require_session(state: &AppState, headers: &HeaderMap) -> Result<(), ApiError> { + if authenticated(state, headers).await { + Ok(()) + } else { + Err(ApiError::unauthorized()) + } +} + +async fn authenticated(state: &AppState, headers: &HeaderMap) -> bool { + let Some(session_id) = session_cookie(headers) else { + return false; + }; + let key = hash_session(&session_id); + let now = unix_now(); + let mut sessions = state.sessions.write().await; + sessions.retain(|_, expiry| *expiry > now); + sessions.get(&key).is_some_and(|expiry| *expiry > now) +} + +fn session_cookie(headers: &HeaderMap) -> Option { + headers + .get(header::COOKIE)? + .to_str() + .ok()? + .split(';') + .find_map(|part| { + let (name, value) = part.trim().split_once('=')?; + (name == SESSION_COOKIE + && !value.is_empty() + && value.len() <= 128 + && value.bytes().all(|byte| byte.is_ascii_alphanumeric())) + .then(|| value.to_string()) + }) +} + +fn hash_session(value: &str) -> String { + format!("{:x}", Sha256::digest(value.as_bytes())) +} + +fn validate_branch(raw: &str) -> Result { + let value = raw.trim(); + if value.is_empty() + || value.len() > 160 + || value.starts_with('-') + || value.starts_with('/') + || value.ends_with('/') + || value.contains("..") + || value.contains("@{") + || value.ends_with('.') + || value.ends_with(".lock") + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'_' | b'-' | b'.')) + { + return Err(ApiError::bad_request("分支名格式无效")); + } + Ok(value.to_string()) +} + +fn validate_commit(raw: &str) -> Result { + let value = raw.trim(); + if !(7..=40).contains(&value.len()) || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(ApiError::bad_request( + "commit hash 必须是 7 到 40 位十六进制字符", + )); + } + Ok(value.to_ascii_lowercase()) +} + +fn validate_deployment_id(value: &str) -> Result<(), ApiError> { + if value.len() != 24 + || !value.starts_with("preview-") + || !value[8..] + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + { + return Err(ApiError::bad_request("部署 ID 格式无效")); + } + Ok(()) +} + +fn derive_deployment_id(branch: &str) -> String { + let digest = format!("{:x}", Sha256::digest(branch.as_bytes())); + format!("preview-{}", &digest[..16]) +} + +fn map_artifact_status(value: &str) -> Option { + match value.to_ascii_uppercase().as_str() { + "QUEUED" => Some(DeploymentStatus::Queued), + "BUILDING" => Some(DeploymentStatus::Building), + "DEPLOYING" => Some(DeploymentStatus::Deploying), + "RUNNING" | "UNHEALTHY" => Some(DeploymentStatus::Running), + "UNINSTALLING" => Some(DeploymentStatus::Uninstalling), + "UNINSTALLED" | "STOPPED" | "NOT_FOUND" => Some(DeploymentStatus::Stopped), + "FAILED" => Some(DeploymentStatus::Failed), + "CANCELLED" => Some(DeploymentStatus::Cancelled), + _ => None, + } +} + +fn map_artifact_health(value: &str) -> Option { + match value.to_ascii_uppercase().as_str() { + "PENDING" => Some(HealthStatus::Pending), + "HEALTHY" => Some(HealthStatus::Healthy), + "UNHEALTHY" => Some(HealthStatus::Unhealthy), + "UNKNOWN" | "UNINSTALLED" | "NOT_FOUND" => Some(HealthStatus::Unknown), + _ => None, + } +} + +fn is_safe_web_url(value: &str, expected_host: &str) -> bool { + url::Url::parse(value).is_ok_and(|url| { + url.scheme() == "http" + && url.host_str() == Some(expected_host) + && url.username().is_empty() + && url.password().is_none() + && url.path() == "/" + && url.query().is_none() + && url.fragment().is_none() + && url.port().is_some_and(|port| (8400..=8499).contains(&port)) + }) +} + +fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { + let mut difference = left.len() ^ right.len(); + let length = left.len().max(right.len()); + for index in 0..length { + difference |= usize::from(*left.get(index).unwrap_or(&0) ^ *right.get(index).unwrap_or(&0)); + } + difference == 0 +} + +fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +#[cfg(test)] +mod tests; diff --git a/server-rs/crates/preview-deployer-server/src/main.rs b/server-rs/crates/preview-deployer-server/src/main.rs new file mode 100644 index 000000000..394735789 --- /dev/null +++ b/server-rs/crates/preview-deployer-server/src/main.rs @@ -0,0 +1,42 @@ +use preview_deployer_server::{AppState, Config, build_router}; +use tracing::info; +use tracing_subscriber::EnvFilter; + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("info,tower_http=info")), + ) + .init(); + + let config = Config::from_env().unwrap_or_else(|error| { + eprintln!("预览部署服务配置无效: {error}"); + std::process::exit(2); + }); + let listener = tokio::net::TcpListener::bind(&config.bind_address) + .await + .unwrap_or_else(|error| { + eprintln!("预览部署服务监听失败: {error}"); + std::process::exit(2); + }); + let address = listener + .local_addr() + .expect("bound listener has local address"); + let state = AppState::new(config).unwrap_or_else(|error| { + eprintln!("预览部署服务初始化失败: {error}"); + std::process::exit(2); + }); + info!(%address, "preview deployer server started"); + + axum::serve(listener, build_router(state)) + .with_graceful_shutdown(async { + let _ = tokio::signal::ctrl_c().await; + }) + .await + .unwrap_or_else(|error| { + eprintln!("预览部署服务退出: {error}"); + std::process::exit(1); + }); +} diff --git a/server-rs/crates/preview-deployer-server/src/tests.rs b/server-rs/crates/preview-deployer-server/src/tests.rs new file mode 100644 index 000000000..cbb117687 --- /dev/null +++ b/server-rs/crates/preview-deployer-server/src/tests.rs @@ -0,0 +1,521 @@ +use std::{ + sync::{Arc, Mutex}, + time::Duration, +}; + +use axum::{ + Json, Router, + body::Body, + extract::{Request, State}, + http::{HeaderMap, StatusCode, header}, + response::IntoResponse, + routing::{get, post}, +}; +use http_body_util::BodyExt; +use serde_json::{Value, json}; +use tokio::net::TcpListener; +use tower::ServiceExt; +use url::Url; + +use super::{AppState, Config, build_router}; + +const HOST: &str = "preview.internal:8080"; +const ORIGIN: &str = "http://preview.internal:8080"; + +#[derive(Clone, Default)] +struct MockJenkinsState { + requests: Arc>>, +} + +#[derive(Debug)] +struct RecordedRequest { + path: String, + body: String, + crumb: Option, +} + +async fn start_mock_jenkins() -> (Url, MockJenkinsState) { + let state = MockJenkinsState::default(); + let app = Router::new() + .route("/jenkins/crumbIssuer/api/json", get(|| async { + Json(json!({"crumbRequestField": "Jenkins-Crumb", "crumb": "safe-crumb"})) + })) + .route("/jenkins/job/shared/job/Genarrative-Preview-Deployer/buildWithParameters", post(mock_trigger)) + .route("/jenkins/queue/item/{id}/api/json", get(mock_queue)) + .route("/jenkins/job/shared/job/Genarrative-Preview-Deployer/{build}/api/json", get(mock_build)) + .route("/jenkins/job/shared/job/Genarrative-Preview-Deployer/{build}/artifact/preview-result.json", get(mock_artifact)) + .with_state(state.clone()); + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("mock Jenkins binds"); + let address = listener.local_addr().expect("mock Jenkins address"); + tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("mock Jenkins serves"); + }); + ( + Url::parse(&format!("http://{address}/jenkins/")).unwrap(), + state, + ) +} + +async fn mock_trigger( + State(state): State, + headers: HeaderMap, + request: Request, +) -> impl IntoResponse { + let body = request + .into_body() + .collect() + .await + .expect("form body") + .to_bytes(); + let body = String::from_utf8(body.to_vec()).expect("utf8 form"); + let mut requests = state.requests.lock().expect("request lock"); + let build_number = requests.len() + 1; + requests.push(RecordedRequest { + path: "/jenkins/job/shared/job/Genarrative-Preview-Deployer/buildWithParameters" + .to_string(), + body, + crumb: headers + .get("jenkins-crumb") + .and_then(|value| value.to_str().ok()) + .map(ToOwned::to_owned), + }); + let mut response = StatusCode::CREATED.into_response(); + response.headers_mut().insert( + header::LOCATION, + format!("/jenkins/queue/item/{build_number}/") + .parse() + .unwrap(), + ); + response +} + +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}/")}}), + ) +} + +async fn mock_build() -> Json { + Json(json!({"building": false, "result": "SUCCESS"})) +} + +async fn mock_artifact(axum::extract::Path(build): axum::extract::Path) -> Json { + if build == 1 { + Json(json!({ + "schemaVersion": 1, + "action": "DEPLOY", + "deploymentId": "preview-63d38d3da6bc9b06", + "projectName": "genarrative-preview-63d38d3da6bc9b06", + "branch": "feature/preview-ui", + "sourceCommit": "0123456789abcdef0123456789abcdef01234567", + "phase": "RUNNING", + "healthStatus": "HEALTHY", + "webUrl": "http://192.168.35.82:8400", + "message": "预览实例已发布" + })) + } else { + Json(json!({ + "schemaVersion": 1, + "action": "UNINSTALL", + "deploymentId": "preview-63d38d3da6bc9b06", + "projectName": "genarrative-preview-63d38d3da6bc9b06", + "branch": "feature/preview-ui", + "phase": "UNINSTALLED", + "healthStatus": "UNINSTALLED", + "message": "预览实例已卸载" + })) + } +} + +fn test_config(jenkins_base_url: Url) -> Config { + let job_url = jenkins_base_url + .join("job/shared/job/Genarrative-Preview-Deployer/") + .unwrap(); + Config { + bind_address: "127.0.0.1:0".to_string(), + jenkins_root_url: jenkins_base_url, + jenkins_base_url: job_url, + jenkins_username: "preview-service".to_string(), + jenkins_api_token: "server-only-jenkins-token".to_string(), + access_token: "correct horse battery staple".to_string(), + allowed_hosts: vec![HOST.to_string()], + allowed_origins: vec![ORIGIN.to_string()], + preview_web_host: "192.168.35.82".to_string(), + secure_cookie: false, + static_dir: None, + state_file: std::env::temp_dir().join(format!( + "preview-deployer-test-{}.json", + uuid::Uuid::new_v4().simple() + )), + poll_interval: Duration::from_millis(5), + } +} + +fn api_request(method: &str, uri: &str, body: Body) -> axum::http::Request { + axum::http::Request::builder() + .method(method) + .uri(uri) + .header(header::HOST, HOST) + .header(header::ORIGIN, ORIGIN) + .header(header::CONTENT_TYPE, "application/json") + .body(body) + .unwrap() +} + +async fn login_cookie(app: &Router) -> String { + let response = app + .clone() + .oneshot(api_request( + "POST", + "/api/preview-deployer/session", + Body::from(r#"{"accessToken":"correct horse battery staple"}"#), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let cookie = response + .headers() + .get(header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap(); + assert!(cookie.contains("HttpOnly")); + assert!(cookie.contains("SameSite=Strict")); + cookie.split(';').next().unwrap().to_string() +} + +#[tokio::test] +async fn session_and_request_boundaries_reject_anonymous_or_cross_origin_requests() { + let (jenkins_url, _) = start_mock_jenkins().await; + let app = build_router(AppState::new(test_config(jenkins_url)).unwrap()); + + let anonymous = app + .clone() + .oneshot(api_request( + "GET", + "/api/preview-deployer/deployments", + Body::empty(), + )) + .await + .unwrap(); + assert_eq!(anonymous.status(), StatusCode::UNAUTHORIZED); + + let bad_login = app + .clone() + .oneshot(api_request( + "POST", + "/api/preview-deployer/session", + Body::from(r#"{"accessToken":"wrong"}"#), + )) + .await + .unwrap(); + assert_eq!(bad_login.status(), StatusCode::UNAUTHORIZED); + + let cross_origin = axum::http::Request::builder() + .method("POST") + .uri("/api/preview-deployer/session") + .header(header::HOST, HOST) + .header(header::ORIGIN, "http://evil.invalid") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + r#"{"accessToken":"correct horse battery staple"}"#, + )) + .unwrap(); + assert_eq!( + app.clone().oneshot(cross_origin).await.unwrap().status(), + StatusCode::FORBIDDEN + ); + + let bad_host = axum::http::Request::builder() + .method("GET") + .uri("/api/preview-deployer/session") + .header(header::HOST, "evil.invalid") + .body(Body::empty()) + .unwrap(); + assert_eq!( + app.oneshot(bad_host).await.unwrap().status(), + StatusCode::FORBIDDEN + ); +} + +#[tokio::test] +async fn healthz_is_available_without_a_control_session() { + let (jenkins_url, _) = start_mock_jenkins().await; + let app = build_router(AppState::new(test_config(jenkins_url)).unwrap()); + let response = app + .oneshot( + axum::http::Request::builder() + .method("GET") + .uri("/healthz") + .header(header::HOST, HOST) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NO_CONTENT); +} + +#[test] +fn config_debug_never_exposes_access_or_jenkins_tokens() { + let root = Url::parse("http://127.0.0.1:8080/jenkins/").unwrap(); + let config = test_config(root); + let debug = format!("{config:?}"); + assert!(!debug.contains("server-only-jenkins-token")); + assert!(!debug.contains("correct horse battery staple")); + assert!(debug.contains("jenkins_api_token_configured: true")); + assert!(debug.contains("access_token_configured: true")); +} + +#[tokio::test] +async fn deploy_and_uninstall_use_fixed_job_and_apply_owned_artifacts() { + let (jenkins_url, mock) = start_mock_jenkins().await; + let state = AppState::new(test_config(jenkins_url)).unwrap(); + let app = build_router(state.clone()); + let cookie = login_cookie(&app).await; + + let invalid = 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":"--upload-pack=evil"}"#)) + .unwrap(); + assert_eq!( + app.clone().oneshot(invalid).await.unwrap().status(), + StatusCode::BAD_REQUEST + ); + assert!(mock.requests.lock().unwrap().is_empty()); + + let id = super::derive_deployment_id("feature/preview-ui"); + assert_eq!(id, "preview-63d38d3da6bc9b06"); + 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","commitHash":"abcdef1"}"#, + )) + .unwrap(); + assert_eq!( + app.clone().oneshot(deploy_request).await.unwrap().status(), + StatusCode::ACCEPTED + ); + wait_for_status(&state, &id, super::DeploymentStatus::Running) + .await + .expect("deployment reaches running"); + + let requests = mock.requests.lock().unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!( + requests[0].path, + "/jenkins/job/shared/job/Genarrative-Preview-Deployer/buildWithParameters" + ); + assert_eq!(requests[0].crumb.as_deref(), Some("safe-crumb")); + assert!(requests[0].body.contains("ACTION=DEPLOY")); + assert!( + requests[0] + .body + .contains("DEPLOYMENT_ID=preview-63d38d3da6bc9b06") + ); + assert!( + requests[0] + .body + .contains("SOURCE_BRANCH=feature%2Fpreview-ui") + ); + assert!(requests[0].body.contains("COMMIT_HASH=abcdef1")); + assert!(!requests[0].body.contains("JOB")); + drop(requests); + + let deployment = state + .deployments + .read() + .await + .get(&id) + .unwrap() + .public + .clone(); + assert_eq!(deployment.status, super::DeploymentStatus::Running); + assert_eq!(deployment.health, super::HealthStatus::Healthy); + assert_eq!( + deployment.web_url.as_deref(), + Some("http://192.168.35.82:8400") + ); + assert_eq!( + deployment.resolved_commit.as_deref(), + Some("0123456789abcdef0123456789abcdef01234567") + ); + assert!(deployment.can_uninstall); + + let uninstall_request = axum::http::Request::builder() + .method("POST") + .uri(format!("/api/preview-deployer/deployments/{id}/uninstall")) + .header(header::HOST, HOST) + .header(header::ORIGIN, ORIGIN) + .header(header::COOKIE, &cookie) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from("{}")) + .unwrap(); + assert_eq!( + app.clone() + .oneshot(uninstall_request) + .await + .unwrap() + .status(), + StatusCode::ACCEPTED + ); + wait_for_status(&state, &id, super::DeploymentStatus::Stopped) + .await + .expect("deployment reaches stopped"); + let requests = mock.requests.lock().unwrap(); + assert_eq!(requests.len(), 2); + assert!(requests[1].body.contains("ACTION=UNINSTALL")); + assert!( + requests[1] + .body + .contains("SOURCE_BRANCH=feature%2Fpreview-ui") + ); + assert!( + requests[1] + .body + .contains("DEPLOYMENT_ID=preview-63d38d3da6bc9b06") + ); + assert!(!requests[1].body.contains("CONTAINER")); + drop(requests); + + let deployment = state + .deployments + .read() + .await + .get(&id) + .unwrap() + .public + .clone(); + assert_eq!(deployment.status, super::DeploymentStatus::Stopped); + assert_eq!(deployment.health, super::HealthStatus::Unknown); + assert_eq!(deployment.web_url, None); + assert!(!deployment.can_uninstall); + 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(); + let recovered = AppState::new(recovered_config); + assert!(recovered.is_ok()); + assert_eq!( + recovered + .unwrap() + .deployments + .read() + .await + .get(&id) + .unwrap() + .public + .status, + super::DeploymentStatus::Stopped + ); + std::fs::remove_file(state_file).unwrap(); +} + +#[tokio::test] +async fn duplicate_active_branch_is_rejected_without_second_jenkins_trigger() { + let (jenkins_url, mock) = start_mock_jenkins().await; + 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/busy"); + let now = super::unix_now(); + state.deployments.write().await.insert( + id.clone(), + super::DeploymentRecord { + public: super::Deployment { + id, + branch: "feature/busy".to_string(), + commit_hash: None, + resolved_commit: None, + status: super::DeploymentStatus::Building, + health: super::HealthStatus::Pending, + web_url: None, + jenkins_build_url: None, + created_at: now, + updated_at: now, + message: None, + can_uninstall: false, + }, + operation: super::Operation::Deploy, + queue_url: None, + }, + ); + let 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/busy"}"#)) + .unwrap(); + assert_eq!( + app.oneshot(request).await.unwrap().status(), + StatusCode::CONFLICT + ); + assert!(mock.requests.lock().unwrap().is_empty()); +} + +async fn wait_for_status( + state: &AppState, + id: &str, + expected: super::DeploymentStatus, +) -> Result<(), ()> { + for _ in 0..100 { + if state + .deployments + .read() + .await + .get(id) + .is_some_and(|record| record.public.status == expected) + { + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + Err(()) +} + +#[test] +fn branch_commit_and_web_url_validation_are_strict() { + assert!(super::validate_branch("feature/preview-ui").is_ok()); + assert!(super::validate_branch("../master").is_err()); + assert!(super::validate_branch("main;curl evil").is_err()); + assert!(super::validate_commit("abcdef1").is_ok()); + assert!(super::validate_commit("HEAD~1").is_err()); + assert!(super::validate_deployment_id("preview-63d38d3da6bc9b06").is_ok()); + assert!(super::validate_deployment_id("preview-63D38D3DA6BC9B06").is_err()); + assert!(super::is_safe_web_url( + "http://192.168.35.82:8400", + "192.168.35.82" + )); + assert!(!super::is_safe_web_url( + "javascript:alert(1)", + "192.168.35.82" + )); + assert!(!super::is_safe_web_url( + "http://user:pass@preview.internal", + "192.168.35.82" + )); + assert!(!super::is_safe_web_url( + "http://192.168.35.82:8500", + "192.168.35.82" + )); + assert!(!super::is_safe_web_url( + "http://station.genarrative.world:8400", + "192.168.35.82" + )); +} From 1746c77cb1c962848ec39fd20f308e32ef426876 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 15 Aug 2026 17:25:40 +0800 Subject: [PATCH 02/19] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E5=8F=B0=E5=AD=90=E8=B7=AF=E5=BE=84=E9=9D=99?= =?UTF-8?q?=E6=80=81=E8=B5=84=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让控制服务原生托管 /build 页面与静态资源 保持内网 Nginx 路径透传并补充部署合同检查 --- .../nginx/genarrative-preview-deployer-lan.conf | 2 +- scripts/check-preview-deployer.mjs | 9 +++++++++ .../crates/preview-deployer-server/src/lib.rs | 15 ++++++++++----- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/deploy/nginx/genarrative-preview-deployer-lan.conf b/deploy/nginx/genarrative-preview-deployer-lan.conf index 82e698937..01cfb6817 100644 --- a/deploy/nginx/genarrative-preview-deployer-lan.conf +++ b/deploy/nginx/genarrative-preview-deployer-lan.conf @@ -3,7 +3,7 @@ location = /build { } location ^~ /build/ { - proxy_pass http://127.0.0.1:8410/; + proxy_pass http://127.0.0.1:8410; proxy_http_version 1.1; proxy_set_header Host $host; diff --git a/scripts/check-preview-deployer.mjs b/scripts/check-preview-deployer.mjs index 603956cb4..230052052 100644 --- a/scripts/check-preview-deployer.mjs +++ b/scripts/check-preview-deployer.mjs @@ -21,6 +21,10 @@ const systemd = readFileSync( 'deploy/systemd/genarrative-preview-deployer.service', 'utf8', ); +const server = readFileSync( + 'server-rs/crates/preview-deployer-server/src/lib.rs', + 'utf8', +); const nginx = readFileSync( 'deploy/nginx/genarrative-preview-deployer-lan.conf', 'utf8', @@ -118,6 +122,11 @@ assertIncludes( 'location ^~ /build/', '内网 Nginx 必须提供 /build/ 路由。', ); +assertIncludes( + server, + '.nest_service("/build/assets", ServeDir::new(static_dir.join("assets")))', + '控制服务必须原生托管 /build 子路径,不能依赖 Nginx 隐式改写。', +); assertIncludes( jobConfig, 'jenkins/Jenkinsfile.preview-deployer', diff --git a/server-rs/crates/preview-deployer-server/src/lib.rs b/server-rs/crates/preview-deployer-server/src/lib.rs index 4915763ee..11e79db54 100644 --- a/server-rs/crates/preview-deployer-server/src/lib.rs +++ b/server-rs/crates/preview-deployer-server/src/lib.rs @@ -234,11 +234,16 @@ pub fn build_router(state: AppState) -> Router { let mut router = Router::new().merge(api); if let Some(static_dir) = state.config.static_dir.clone() { - router = router.fallback_service( - ServeDir::new(&static_dir) - .append_index_html_on_directories(true) - .fallback(ServeFile::new(static_dir.join("index.html"))), - ); + let index = static_dir.join("index.html"); + router = router + .route_service("/build", ServeFile::new(index.clone())) + .route_service("/build/", ServeFile::new(index.clone())) + .nest_service("/build/assets", ServeDir::new(static_dir.join("assets"))) + .fallback_service( + ServeDir::new(&static_dir) + .append_index_html_on_directories(true) + .fallback(ServeFile::new(index)), + ); } router From 6befa3c33a2b6dff31509fc42272c2f468582d40 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 15 Aug 2026 17:29:25 +0800 Subject: [PATCH 03/19] =?UTF-8?q?=E8=A1=A5=E5=85=A8=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E6=B5=81=E6=B0=B4=E7=BA=BF=E9=A6=96=E6=AC=A1=E8=A7=A6=E5=8F=91?= =?UTF-8?q?=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 Jenkins Job 配置中预声明部署参数 增加 buildWithParameters 首次触发合同检查 --- jenkins/preview-deployer-job-config.xml | 32 ++++++++++++++++++++++++- scripts/check-preview-deployer.mjs | 5 ++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/jenkins/preview-deployer-job-config.xml b/jenkins/preview-deployer-job-config.xml index a2317950f..a0cbd4247 100644 --- a/jenkins/preview-deployer-job-config.xml +++ b/jenkins/preview-deployer-job-config.xml @@ -5,7 +5,37 @@ false - + + + ACTION + 部署、查询状态或卸载分支预览容器 + + + DEPLOY + STATUS + UNINSTALL + + + + + SOURCE_BRANCH + 源码分支;DEPLOY 和 UNINSTALL 必填 + + true + + + COMMIT_HASH + 可选;必须是 SOURCE_BRANCH 历史中的 commit + + true + + + DEPLOYMENT_ID + 预览实例稳定 ID + + true + + diff --git a/scripts/check-preview-deployer.mjs b/scripts/check-preview-deployer.mjs index 230052052..32d1b0497 100644 --- a/scripts/check-preview-deployer.mjs +++ b/scripts/check-preview-deployer.mjs @@ -132,6 +132,11 @@ assertIncludes( 'jenkins/Jenkinsfile.preview-deployer', 'Jenkins Job 必须从受保护 master 读取固定流水线。', ); +assertIncludes( + jobConfig, + '', + 'Jenkins Job 首次触发前必须已经声明 buildWithParameters 参数。', +); assertIncludes( nginx, 'location ^~ /api/preview-deployer/', From 7400c706f8113c57cd6db3540e5273c59acc0f1a Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 15 Aug 2026 17:39:07 +0800 Subject: [PATCH 04/19] =?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=E5=8F=82=E6=95=B0=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在严格 shell 模式下归一化首次触发的空参数 避免 STATUS 与未指定 commit 的请求误报未定义变量 --- jenkins/Jenkinsfile.preview-deployer | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/jenkins/Jenkinsfile.preview-deployer b/jenkins/Jenkinsfile.preview-deployer index 6591da10f..3f4e5961e 100644 --- a/jenkins/Jenkinsfile.preview-deployer +++ b/jenkins/Jenkinsfile.preview-deployer @@ -47,6 +47,10 @@ pipeline { 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 From d2366bc40a417a0b762303e03db883b296fb2d3d Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 15 Aug 2026 17:59:27 +0800 Subject: [PATCH 05/19] =?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 06/19] =?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 07/19] =?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 08/19] =?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 09/19] =?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 10/19] =?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 11/19] =?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 12/19] =?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 13/19] =?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 14/19] =?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 15/19] =?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 16/19] =?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(); +} From abb39125303d326c92083512c3dbb6ee3bf306e9 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 17 Aug 2026 12:04:31 +0800 Subject: [PATCH 17/19] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=88=86=E6=94=AF?= =?UTF-8?q?=E4=B8=8E=E6=8F=90=E4=BA=A4=E6=90=9C=E7=B4=A2=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 增加分支和 commit 的输入搜索下拉构建前复核固定仓库中的分支与 commit 归属补充预览控制面接口合同、配置和测试 --- .../src/PreviewDeployerApp.test.tsx | 85 ++++ .../src/PreviewDeployerApp.tsx | 380 +++++++++++++++++- apps/preview-deployer-web/src/api.test.ts | 44 ++ apps/preview-deployer-web/src/api.ts | 31 ++ apps/preview-deployer-web/src/styles.css | 67 ++- deploy/env/preview-deployer.env.example | 4 + ...Jenkins容器预览部署控制面技术方案-2026-08-15.md | 5 + ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- .../crates/preview-deployer-server/Cargo.toml | 2 +- .../preview-deployer-server/src/config.rs | 40 +- .../preview-deployer-server/src/git_refs.rs | 282 +++++++++++++ .../crates/preview-deployer-server/src/lib.rs | 153 ++++++- .../preview-deployer-server/src/tests.rs | 76 +++- 13 files changed, 1147 insertions(+), 24 deletions(-) create mode 100644 server-rs/crates/preview-deployer-server/src/git_refs.rs diff --git a/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx b/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx index 261e5a6ae..21f2b900d 100644 --- a/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx +++ b/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx @@ -24,6 +24,8 @@ beforeEach(() => { vi.mocked(api.createSession).mockResolvedValue({ authenticated: true }); vi.mocked(api.deleteSession).mockResolvedValue(undefined); vi.mocked(api.listDeployments).mockResolvedValue([]); + vi.mocked(api.searchBranches).mockResolvedValue([]); + vi.mocked(api.searchCommits).mockResolvedValue([]); vi.mocked(api.createDeployment).mockResolvedValue({ id: 'preview-1', branch: 'master', @@ -108,3 +110,86 @@ test('shows health and web url, then confirms uninstall', async () => { expect(api.uninstallDeployment).toHaveBeenCalledWith('preview-2'); }); }); + +test('shows debounced branch results and selects one with the keyboard', async () => { + const user = userEvent.setup(); + vi.mocked(api.searchBranches).mockResolvedValue([ + { name: 'feature/search-one', commitHash: '111111111111' }, + { name: 'feature/search-two', commitHash: '222222222222' }, + ]); + render(); + + const branchInput = await screen.findByRole('combobox', { name: '分支名' }); + await user.clear(branchInput); + await user.type(branchInput, 'feature/search'); + + expect( + await screen.findByRole('option', { name: /feature\/search-one/u }), + ).toBeTruthy(); + expect(api.searchBranches).toHaveBeenLastCalledWith( + 'feature/search', + expect.any(AbortSignal), + ); + await user.keyboard('{ArrowDown}{ArrowDown}{Enter}'); + expect((branchInput as HTMLInputElement).value).toBe('feature/search-two'); +}); + +test('searches commits in the current branch and selects a result', async () => { + const user = userEvent.setup(); + vi.mocked(api.searchCommits).mockResolvedValue([ + { + commitHash: 'aa5221abcdef0123456789', + shortHash: 'aa5221a', + subject: '补充预览搜索', + }, + ]); + render(); + + const commitInput = await screen.findByRole('combobox', { + name: 'Commit Hash', + }); + await user.type(commitInput, 'aa5221a'); + + const option = await screen.findByRole('option', { + name: /aa5221a.*补充预览搜索/u, + }); + expect(api.searchCommits).toHaveBeenCalledWith( + 'master', + 'aa5221a', + expect.any(AbortSignal), + ); + await user.click(option); + expect((commitInput as HTMLInputElement).value).toBe( + 'aa5221abcdef0123456789', + ); +}); + +test('clears commit and ignores stale commit responses when branch changes', async () => { + const user = userEvent.setup(); + let resolveOldSearch: ((items: api.CommitRef[]) => void) | undefined; + vi.mocked(api.searchCommits).mockImplementation( + () => + new Promise((resolve) => { + resolveOldSearch = resolve; + }), + ); + render(); + + const branchInput = await screen.findByRole('combobox', { name: '分支名' }); + const commitInput = screen.getByRole('combobox', { name: 'Commit Hash' }); + await user.type(commitInput, 'abcdef1'); + await waitFor(() => expect(api.searchCommits).toHaveBeenCalled()); + await user.clear(branchInput); + await user.type(branchInput, 'feature/new'); + + expect((commitInput as HTMLInputElement).value).toBe(''); + resolveOldSearch?.([ + { + commitHash: 'abcdef1234567890', + shortHash: 'abcdef1', + subject: '旧分支提交', + }, + ]); + await Promise.resolve(); + expect(screen.queryByText('旧分支提交')).toBeNull(); +}); diff --git a/apps/preview-deployer-web/src/PreviewDeployerApp.tsx b/apps/preview-deployer-web/src/PreviewDeployerApp.tsx index 2bfd2482a..45f7e7d80 100644 --- a/apps/preview-deployer-web/src/PreviewDeployerApp.tsx +++ b/apps/preview-deployer-web/src/PreviewDeployerApp.tsx @@ -13,15 +13,27 @@ import { TriangleAlert, XCircle, } from 'lucide-react'; -import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react'; +import { + FormEvent, + KeyboardEvent as ReactKeyboardEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { + type BranchRef, + type CommitRef, createDeployment, createSession, deleteSession, getSession, listDeployments, PreviewDeployerApiError, + searchBranches, + searchCommits, uninstallDeployment, } from './api'; import type { @@ -32,6 +44,7 @@ import type { import { validateBranch, validateCommitHash } from './validation'; const POLL_INTERVAL_MS = 5000; +const SEARCH_DEBOUNCE_MS = 300; const STATUS_LABELS: Record = { queued: '排队中', @@ -66,6 +79,16 @@ export function PreviewDeployerApp() { const [submitting, setSubmitting] = useState(false); const [notice, setNotice] = useState(''); const [error, setError] = useState(''); + const [branchSearch, setBranchSearch] = + useState>(emptySearchState()); + const [commitSearch, setCommitSearch] = + useState>(emptySearchState()); + const [branchActiveIndex, setBranchActiveIndex] = useState(-1); + const [commitActiveIndex, setCommitActiveIndex] = useState(-1); + const [branchSearchEnabled, setBranchSearchEnabled] = useState(false); + const [commitSearchEnabled, setCommitSearchEnabled] = useState(false); + const branchRequestId = useRef(0); + const commitRequestId = useRef(0); const [pendingUninstall, setPendingUninstall] = useState(null); const [uninstallingId, setUninstallingId] = useState(''); @@ -129,6 +152,94 @@ export function PreviewDeployerApp() { return () => window.clearInterval(interval); }, [refresh, sessionStatus]); + useEffect(() => { + const query = branch.trim(); + const requestId = ++branchRequestId.current; + const controller = new AbortController(); + if (sessionStatus !== 'authenticated' || !branchSearchEnabled || !query) { + setBranchSearch(emptySearchState()); + setBranchActiveIndex(-1); + return () => controller.abort(); + } + setBranchSearch({ items: [], loading: true, open: true, error: '' }); + setBranchActiveIndex(-1); + const timer = window.setTimeout(() => { + void searchBranches(query, controller.signal) + .then((items) => { + if (requestId !== branchRequestId.current) return; + setBranchSearch({ items, loading: false, open: true, error: '' }); + }) + .catch((searchError) => { + if ( + controller.signal.aborted || + requestId !== branchRequestId.current + ) + return; + if (isUnauthorized(searchError)) { + handleUnauthorized(); + return; + } + setBranchSearch({ + items: [], + loading: false, + open: true, + error: formatError(searchError), + }); + }); + }, SEARCH_DEBOUNCE_MS); + return () => { + window.clearTimeout(timer); + controller.abort(); + }; + }, [branch, branchSearchEnabled, sessionStatus]); + + useEffect(() => { + const query = commitHash.trim(); + const selectedBranch = branch.trim(); + const requestId = ++commitRequestId.current; + const controller = new AbortController(); + if ( + sessionStatus !== 'authenticated' || + !commitSearchEnabled || + !query || + validateBranch(selectedBranch) + ) { + setCommitSearch(emptySearchState()); + setCommitActiveIndex(-1); + return () => controller.abort(); + } + setCommitSearch({ items: [], loading: true, open: true, error: '' }); + setCommitActiveIndex(-1); + const timer = window.setTimeout(() => { + void searchCommits(selectedBranch, query, controller.signal) + .then((items) => { + if (requestId !== commitRequestId.current) return; + setCommitSearch({ items, loading: false, open: true, error: '' }); + }) + .catch((searchError) => { + if ( + controller.signal.aborted || + requestId !== commitRequestId.current + ) + return; + if (isUnauthorized(searchError)) { + handleUnauthorized(); + return; + } + setCommitSearch({ + items: [], + loading: false, + open: true, + error: formatError(searchError), + }); + }); + }, SEARCH_DEBOUNCE_MS); + return () => { + window.clearTimeout(timer); + controller.abort(); + }; + }, [branch, commitHash, commitSearchEnabled, sessionStatus]); + const runningCount = useMemo( () => deployments.filter((deployment) => deployment.status === 'running') @@ -356,36 +467,166 @@ export function PreviewDeployerApp() { className="deploy-form" onSubmit={(event) => void handleSubmit(event)} > -