Merge remote-tracking branch 'origin/master' into codex/agc-runtime-generation-reliability
# Conflicts: # docs/project-memory/shared-memory/decision-log.md
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<title>Docker 预览发布</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@genarrative/preview-deployer-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"test": "vitest run -c vitest.config.ts",
|
||||
"build": "npm run typecheck && vite build",
|
||||
"preview": "vite preview --host 127.0.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"lucide-react": "^0.546.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"vite": "^6.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"typescript": "~5.8.2",
|
||||
"vitest": "^0.34.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
/* @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.searchBranches).mockResolvedValue([]);
|
||||
vi.mocked(api.searchCommits).mockResolvedValue([]);
|
||||
vi.mocked(api.createDeployment).mockResolvedValue({
|
||||
id: null,
|
||||
branch: 'master',
|
||||
status: 'queued',
|
||||
health: 'pending',
|
||||
createdAt: '2026-08-15T00:00:00Z',
|
||||
updatedAt: '2026-08-15T00:00:00Z',
|
||||
});
|
||||
vi.mocked(api.uninstallDeployment).mockResolvedValue({
|
||||
id: '1',
|
||||
branch: 'master',
|
||||
status: 'uninstalling',
|
||||
health: 'pending',
|
||||
createdAt: '2026-08-15T00:00:00Z',
|
||||
updatedAt: '2026-08-15T00:02:00Z',
|
||||
});
|
||||
});
|
||||
|
||||
test('requires an access token before showing deployments', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.getSession).mockResolvedValue({ authenticated: false });
|
||||
render(<PreviewDeployerApp />);
|
||||
|
||||
expect(await screen.findByText('访问口令')).toBeTruthy();
|
||||
await user.type(screen.getByPlaceholderText('请输入访问口令'), 'team-token');
|
||||
await user.click(screen.getByRole('button', { name: '进入发布面板' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.createSession).toHaveBeenCalledWith('team-token');
|
||||
});
|
||||
expect(await screen.findByText('构建并发布一个分支')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('submits a branch with an optional commit hash', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PreviewDeployerApp />);
|
||||
|
||||
const branchInput = await screen.findByPlaceholderText('master');
|
||||
await user.clear(branchInput);
|
||||
await user.type(branchInput, 'feature/preview');
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('留空则构建分支最新提交'),
|
||||
'aa5221abc',
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: '开始构建' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.createDeployment).toHaveBeenCalledWith({
|
||||
branch: 'feature/preview',
|
||||
commitHash: 'aa5221abc',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('shows health and web url, then confirms uninstall', async () => {
|
||||
vi.mocked(api.listDeployments).mockResolvedValue([
|
||||
{
|
||||
id: '42',
|
||||
branch: 'feature/demo',
|
||||
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,
|
||||
},
|
||||
]);
|
||||
render(<PreviewDeployerApp />);
|
||||
|
||||
expect(await screen.findByText('健康')).toBeTruthy();
|
||||
expect(screen.getByText('端口 8400')).toBeTruthy();
|
||||
expect(screen.getByText('构建 #42')).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('42');
|
||||
});
|
||||
});
|
||||
|
||||
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(<PreviewDeployerApp />);
|
||||
|
||||
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(<PreviewDeployerApp />);
|
||||
|
||||
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<api.CommitRef[]>((resolve) => {
|
||||
resolveOldSearch = resolve;
|
||||
}),
|
||||
);
|
||||
render(<PreviewDeployerApp />);
|
||||
|
||||
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();
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,158 @@
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
createDeployment,
|
||||
createSession,
|
||||
deleteSession,
|
||||
getSession,
|
||||
listDeployments,
|
||||
searchBranches,
|
||||
searchCommits,
|
||||
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' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('searches branches and branch-scoped commits with encoded queries', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
items: [{ name: 'feature/search', commitHash: 'abcdef123456' }],
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
items: [
|
||||
{
|
||||
commitHash: 'abcdef123456',
|
||||
shortHash: 'abcdef1',
|
||||
subject: '搜索提交',
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
expect(await searchBranches('feature/search')).toHaveLength(1);
|
||||
expect(await searchCommits('feature/search', 'abc def')).toHaveLength(1);
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/preview-deployer/refs/branches?q=feature%2Fsearch',
|
||||
expect.objectContaining({ credentials: 'same-origin' }),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/preview-deployer/refs/commits?branch=feature%2Fsearch&q=abc%20def',
|
||||
expect.objectContaining({ credentials: 'same-origin' }),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
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 interface BranchRef {
|
||||
name: string;
|
||||
commitHash?: string | null;
|
||||
}
|
||||
|
||||
export interface CommitRef {
|
||||
commitHash: string;
|
||||
shortHash: string;
|
||||
subject?: string | null;
|
||||
}
|
||||
|
||||
export async function getSession(signal?: AbortSignal) {
|
||||
const session = await request<PreviewDeployerSession | null>('/session', {
|
||||
signal,
|
||||
});
|
||||
return session ?? { authenticated: true };
|
||||
}
|
||||
|
||||
export function createSession(accessToken: string) {
|
||||
return request<PreviewDeployerSession>('/session', {
|
||||
method: 'POST',
|
||||
body: { accessToken },
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteSession() {
|
||||
return request<void>('/session', { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function listDeployments(signal?: AbortSignal) {
|
||||
const payload = await request<
|
||||
PreviewDeployment[] | { deployments: PreviewDeployment[] }
|
||||
>('/deployments', { signal });
|
||||
return Array.isArray(payload) ? payload : payload.deployments;
|
||||
}
|
||||
|
||||
export async function searchBranches(query: string, signal?: AbortSignal) {
|
||||
const payload = await request<BranchRef[] | { items: BranchRef[] }>(
|
||||
`/refs/branches?q=${encodeURIComponent(query)}`,
|
||||
{ signal },
|
||||
);
|
||||
return Array.isArray(payload) ? payload : payload.items;
|
||||
}
|
||||
|
||||
export async function searchCommits(
|
||||
branch: string,
|
||||
query: string,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const payload = await request<CommitRef[] | { items: CommitRef[] }>(
|
||||
`/refs/commits?branch=${encodeURIComponent(branch)}&q=${encodeURIComponent(query)}`,
|
||||
{ signal },
|
||||
);
|
||||
return Array.isArray(payload) ? payload : payload.items;
|
||||
}
|
||||
|
||||
export function createDeployment(input: CreateDeploymentInput) {
|
||||
return request<PreviewDeployment>('/deployments', {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
export function uninstallDeployment(deploymentId: string) {
|
||||
return request<PreviewDeployment>(
|
||||
`/deployments/${encodeURIComponent(deploymentId)}/uninstall`,
|
||||
{ method: 'POST', body: {} },
|
||||
);
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
options: RequestOptions = {},
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
const init: RequestInit = {
|
||||
method: options.method ?? 'GET',
|
||||
headers,
|
||||
signal: options.signal,
|
||||
credentials: 'same-origin',
|
||||
};
|
||||
if (options.body !== undefined) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
init.body = JSON.stringify(options.body);
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}${path}`, init);
|
||||
const responseText = await response.text();
|
||||
const payload = parseJson(responseText);
|
||||
if (!response.ok) {
|
||||
throw new PreviewDeployerApiError(
|
||||
readErrorMessage(payload) || `请求失败(HTTP ${response.status})`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
return unwrapPayload<T>(payload);
|
||||
}
|
||||
|
||||
function parseJson(value: string): unknown {
|
||||
if (!value.trim()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(value) as unknown;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function unwrapPayload<T>(payload: unknown): T {
|
||||
if (isRecord(payload) && 'data' in payload) {
|
||||
return payload.data as T;
|
||||
}
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
function readErrorMessage(payload: unknown) {
|
||||
if (typeof payload === 'string') {
|
||||
return payload.trim();
|
||||
}
|
||||
if (!isRecord(payload)) {
|
||||
return '';
|
||||
}
|
||||
if (typeof payload.message === 'string') {
|
||||
return payload.message;
|
||||
}
|
||||
if (isRecord(payload.error) && typeof payload.error.message === 'string') {
|
||||
return payload.error.message;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import './styles.css';
|
||||
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { PreviewDeployerApp } from './PreviewDeployerApp';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error('Missing #root container');
|
||||
}
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<PreviewDeployerApp />
|
||||
</StrictMode>,
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
export type DeploymentStatus =
|
||||
| 'queued'
|
||||
| 'building'
|
||||
| 'deploying'
|
||||
| 'running'
|
||||
| 'uninstalling'
|
||||
| 'stopped'
|
||||
| 'failed'
|
||||
| 'cancelled';
|
||||
|
||||
export type DeploymentHealth = 'pending' | 'healthy' | 'unhealthy' | 'unknown';
|
||||
|
||||
export interface PreviewDeployment {
|
||||
id?: string | null;
|
||||
branch: string;
|
||||
commitHash?: string | null;
|
||||
resolvedCommit?: string | null;
|
||||
status: DeploymentStatus;
|
||||
health: DeploymentHealth;
|
||||
webPort?: number | null;
|
||||
webUrl?: string | null;
|
||||
jenkinsBuildUrl?: string | null;
|
||||
createdAt: string | number;
|
||||
updatedAt: string | number;
|
||||
message?: string | null;
|
||||
canUninstall?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateDeploymentInput {
|
||||
branch: string;
|
||||
commitHash?: string;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { validateBranch, validateCommitHash } from './validation';
|
||||
|
||||
test('accepts normal branch names and rejects unsafe values', () => {
|
||||
expect(validateBranch('feature/preview-panel')).toBe('');
|
||||
expect(validateBranch('../master')).toBe('分支名格式不正确');
|
||||
expect(validateBranch('feature;rm')).toBe('分支名格式不正确');
|
||||
});
|
||||
|
||||
test('commit hash is optional but must be a git hash when present', () => {
|
||||
expect(validateCommitHash('')).toBe('');
|
||||
expect(validateCommitHash('a0242e35')).toBe('');
|
||||
expect(validateCommitHash('not-a-hash')).not.toBe('');
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
const BRANCH_PATTERN = /^[0-9A-Za-z._/-]+$/u;
|
||||
const COMMIT_PATTERN = /^[0-9a-fA-F]{7,40}$/u;
|
||||
|
||||
export function validateBranch(value: string) {
|
||||
const branch = value.trim();
|
||||
if (!branch) {
|
||||
return '请输入分支名';
|
||||
}
|
||||
if (
|
||||
branch.length > 200 ||
|
||||
!BRANCH_PATTERN.test(branch) ||
|
||||
branch.startsWith('/') ||
|
||||
branch.endsWith('/') ||
|
||||
branch.includes('..')
|
||||
) {
|
||||
return '分支名格式不正确';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function validateCommitHash(value: string) {
|
||||
const commitHash = value.trim();
|
||||
if (commitHash && !COMMIT_PATTERN.test(commitHash)) {
|
||||
return 'Commit Hash 需为 7 到 40 位十六进制字符';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
|
||||
const appRoot = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(appRoot, '../..');
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = {
|
||||
...loadEnv(mode, repoRoot, ''),
|
||||
...loadEnv(mode, appRoot, ''),
|
||||
};
|
||||
const apiTarget = env.PREVIEW_DEPLOYER_API_TARGET ?? 'http://127.0.0.1:8410';
|
||||
|
||||
return {
|
||||
root: appRoot,
|
||||
envDir: repoRoot,
|
||||
base: env.PREVIEW_DEPLOYER_WEB_BASE ?? '/build/',
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api/preview-deployer': {
|
||||
target: apiTarget,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# 仅部署在本机内网 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/
|
||||
# 页面“构建详情”链接使用局域网地址,内部请求仍使用上面的 loopback 地址。
|
||||
GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_PUBLIC_BASE_URL=http://192.168.35.82:8080/jenkins/
|
||||
GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_USERNAME=preview-deployer-service
|
||||
GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_API_TOKEN=<由Jenkins管理员生成的专用API Token>
|
||||
# 只读源码查询固定使用本机 Gitea SSH 入口,控制服务不接受客户端传入 remote。
|
||||
GENARRATIVE_PREVIEW_DEPLOYER_GIT_REMOTE_URL=ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git
|
||||
# 使用 Jenkins 用户专用只读 deploy key 与独立 known_hosts;禁止关闭主机密钥校验。
|
||||
GENARRATIVE_PREVIEW_DEPLOYER_GIT_SSH_COMMAND=ssh -i /var/lib/jenkins/.ssh/genarrative-preview-readonly -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=/var/lib/jenkins/.ssh/known_hosts
|
||||
GENARRATIVE_PREVIEW_DEPLOYER_ACCESS_TOKEN=<至少24字符的控制面访问口令>
|
||||
GENARRATIVE_PREVIEW_DEPLOYER_ALLOWED_HOSTS=192.168.35.82
|
||||
GENARRATIVE_PREVIEW_DEPLOYER_ALLOWED_ORIGINS=http://192.168.35.82
|
||||
GENARRATIVE_PREVIEW_DEPLOYER_WEB_HOST=192.168.35.82
|
||||
GENARRATIVE_PREVIEW_DEPLOYER_STATE_FILE=/var/lib/genarrative/preview-deployer/state.json
|
||||
GENARRATIVE_PREVIEW_DEPLOYER_STATIC_DIR=/opt/genarrative/preview-deployer/web
|
||||
GENARRATIVE_PREVIEW_DEPLOYER_SECURE_COOKIE=false
|
||||
RUST_LOG=info,tower_http=info
|
||||
@@ -0,0 +1,35 @@
|
||||
location = /build {
|
||||
return 301 /build/;
|
||||
}
|
||||
|
||||
location ^~ /build/ {
|
||||
proxy_pass http://127.0.0.1:8410;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 90s;
|
||||
}
|
||||
|
||||
location ^~ /api/preview-deployer/ {
|
||||
proxy_pass http://127.0.0.1:8410;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 90s;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
[Unit]
|
||||
Description=Genarrative Jenkins Preview Deployer
|
||||
After=network-online.target jenkins.service
|
||||
Wants=network-online.target
|
||||
Requires=jenkins.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=jenkins
|
||||
Group=jenkins
|
||||
WorkingDirectory=/opt/genarrative/preview-deployer
|
||||
EnvironmentFile=/etc/genarrative/preview-deployer.env
|
||||
ExecStart=/opt/genarrative/preview-deployer/preview-deployer-server
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
KillSignal=SIGINT
|
||||
TimeoutStopSec=30
|
||||
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/genarrative/preview-deployer
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -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)
|
||||
|
||||
@@ -14192,6 +14192,20 @@
|
||||
- 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`。
|
||||
|
||||
## 2026-08-17 预览发布记录使用 Jenkins 构建编号并有限保留
|
||||
|
||||
- 决策:内部稳定 `deploymentId` 继续绑定分支、Compose project 和端口租约;页面/API 记录 ID 在 Jenkins 分配执行器后改为构建编号,排队阶段为“待分配”。卸载通过构建编号找到内部实例,再向固定 Job 传内部 ID。
|
||||
- 清理:失败或取消且不存在可卸载实例的记录保留 7 天;成功卸载的内部审计记录保留 30 天;仍可卸载的失败记录永久保留到人工卸载。服务启动、读取列表和创建部署时执行清理并原子落盘。
|
||||
- 链接:服务内部仍通过 Jenkins loopback 轮询;只向浏览器返回由 `GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_PUBLIC_BASE_URL` 构造的局域网构建详情地址,禁止回传 loopback URL。
|
||||
|
||||
## 2026-08-16 AGC 直连回合的 revision 与项目版本同步
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# 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。
|
||||
|
||||
## 部署身份与端口
|
||||
|
||||
预览实例 ID(`deploymentId`)由规范化分支名与分支名摘要确定,只在控制服务内部和 Jenkins 参数中使用;commit 不进入该 ID,因此同一分支重新构建或指定不同 commit 时复用同一预览实例和 Web 端口。页面发布记录的公开 ID 使用 Jenkins 构建编号:排队期间尚未分配编号,显示“待分配”,构建开始后立即显示真实编号。
|
||||
|
||||
Web 端口池固定为 `8400..8499`:
|
||||
|
||||
1. Jenkins 在全局文件锁内读取受控状态目录。
|
||||
2. 已登记分支复用原端口;新分支选择状态表、宿主监听和 Docker 映射均未占用的端口。
|
||||
3. Jenkins 为实例使用独立 Compose project,例如 `genarrative-preview-a81f39c2d43e76ab`。
|
||||
4. 端口租约必须在容器完成真实绑定前持续受锁保护;失败构建不得把端口分配给第二个实例。
|
||||
5. 卸载成功后删除该实例的 Compose 栈、受控状态和端口租约。
|
||||
|
||||
同一分支重新构建时先完成新镜像构建,再停止旧 Compose 栈并启动新版本;源码或镜像构建失败不会提前删除原有健康实例。容器切换后的失败仍会在页面明确显示,不伪装成旧版本继续运行。
|
||||
|
||||
页面每次刷新运行中实例时,控制服务直接探测受控 Web URL;后续容器异常会将健康状态更新为 `unhealthy`,不会永久沿用 Jenkins 部署完成时的快照。
|
||||
|
||||
目标分支只提供应用构建上下文。Compose 编排文件、Dockerfile 入口和 Jenkins 执行脚本固定取自受保护的 `master` 控制器 checkout,避免普通分支替换编排文件挂载宿主路径或启用特权容器。该 Job 仍只应开放给受信任开发者,并建议在隔离构建节点运行。
|
||||
|
||||
SpacetimeDB 与 OTLP 不映射宿主端口;Jenkins 通过受控 Compose 网络中的 SpacetimeDB 容器地址完成模块发布,运行服务之间继续使用 Compose DNS。页面只展示 Web 内网地址 `http://<预览宿主>:<webPort>`。
|
||||
|
||||
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`。
|
||||
|
||||
参数:
|
||||
|
||||
- `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`:列出由控制面触发和恢复的部署。
|
||||
- `GET /api/preview-deployer/refs/branches?q=`:按输入内容搜索固定源码仓库中的分支,最多返回 20 条 `{ name, commitHash }`。
|
||||
- `GET /api/preview-deployer/refs/commits?branch=&q=`:在已确认存在的目标分支历史中搜索 commit,最多返回 20 条 `{ commitHash, shortHash, subject }`。
|
||||
- `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 构建编号、后端校验后的 `webPort` 和由控制服务转换的 Jenkins 局域网构建详情地址;内部 loopback Jenkins 地址不得返回浏览器。失败或取消且不存在可卸载实例的记录保留 7 天,成功卸载的内部记录保留 30 天,清理会在服务启动、读取列表和创建新构建时执行。仍可卸载的失败记录不会自动清理。`stopped` 记录仍保留在服务端持久状态中用于审计和所有权校验,但列表 API 不再返回,页面刷新后立即从发布记录中消失。
|
||||
|
||||
分支名和 commit 输入框采用 300ms 防抖搜索,并在输入框下方显示服务端结果;分支变化时清空已输入的 commit,避免把旧分支 commit 带入新请求。搜索结果只负责辅助填写,不作为构建授权或存在性真相。`POST /deployments` 在写入排队状态和触发 Jenkins 前必须重新查询固定远端:分支不存在时拒绝;填写 commit 时必须确认它可解析为 commit object 且是目标分支 HEAD 的祖先。远端查询失败时失败关闭,不得触发 Jenkins。Jenkins checkout 继续执行相同的最终归属校验,以覆盖预检到排队之间的分支变化。
|
||||
|
||||
Jenkins 在构建完成、归档 artifact 和更新 REST 状态之间可能短暂返回不可解析的状态正文。控制服务对队列、构建状态和 artifact 查询执行有限重试;单次瞬态响应不得把已经成功并健康的部署永久写成 `failed`。
|
||||
|
||||
控制服务查询 Jenkins 队列与构建状态时必须使用 `tree` 参数限制到所需字段,避免完整 `api/json` 的大体积深层对象触发 JSON 递归深度限制。预览 Compose 中的外部生成 worker 使用 `restart: on-failure`;它若早于 API 完成模型定价运行时身份初始化而启动失败,应由 Docker 自动重启并在身份就绪后稳定运行。
|
||||
|
||||
## 安全边界
|
||||
|
||||
- 服务端缺少控制面访问口令或 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 凭据。
|
||||
- Git 查询固定使用本机 Gitea SSH 地址和服务端只读凭据;客户端不能传 remote、SSH 参数或凭据。Git 缓存只写入预览控制服务的受控状态目录,搜索接口需要控制台会话且结果有数量上限。
|
||||
- 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`。
|
||||
@@ -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。分支和 commit 输入框通过受认证的控制服务搜索固定内网 Git 仓库并展示下拉结果;提交构建前控制服务重新确认分支存在、可选 commit 存在且属于目标分支,失败时不触发 Jenkins,Jenkins checkout 仍保留最终复核。每个分支使用稳定的内部 `deploymentId` 和独立 Compose project,Web 端口从 `8400..8499` 在文件锁内分配,同一分支换 commit 优先复用端口,卸载后释放;页面记录 ID 使用 Jenkins 构建编号。Jenkins 用 `preview-result.json` 向页面提供 resolved commit、发布结果和内网 Web URL,页面刷新时由控制服务实时复核 Web 健康;构建详情链接固定使用局域网 Jenkins 地址,不暴露 loopback 地址。失败/取消且不可卸载的记录保留 7 天,停止记录保留 30 天,仍可卸载的失败记录不会自动清理。安装资产为 `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 排空另行验证。
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
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'
|
||||
GENARRATIVE_PREVIEW_WEB_HOST = '192.168.35.82'
|
||||
}
|
||||
|
||||
parameters {
|
||||
choice(name: 'ACTION', choices: ['DEPLOY', 'STATUS', 'UNINSTALL'], description: '部署、查询状态或卸载分支预览容器')
|
||||
string(name: 'SOURCE_BRANCH', defaultValue: '', description: '源码分支;DEPLOY 必填,同一分支稳定映射到同一个预览实例')
|
||||
string(name: 'COMMIT_HASH', defaultValue: '', description: '可选;必须是 SOURCE_BRANCH 历史中的 commit')
|
||||
string(name: 'DEPLOYMENT_ID', defaultValue: '', description: '可选;留空时由 SOURCE_BRANCH 稳定生成,查询和卸载可直接传部署 ID')
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Checkout Controller') {
|
||||
steps {
|
||||
checkout([
|
||||
$class: 'GitSCM',
|
||||
branches: [[name: '*/master']],
|
||||
doGenerateSubmoduleConfigurations: false,
|
||||
extensions: [
|
||||
[$class: 'CleanBeforeCheckout'],
|
||||
[$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true],
|
||||
],
|
||||
userRemoteConfigs: [[
|
||||
url: env.GIT_REMOTE_URL,
|
||||
credentialsId: env.GIT_REMOTE_CREDENTIAL_ID,
|
||||
refspec: '+refs/heads/master:refs/remotes/origin/master',
|
||||
]],
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
stage('Validate Request') {
|
||||
steps {
|
||||
sh '''
|
||||
set -euo pipefail
|
||||
ACTION="${ACTION:-}"
|
||||
SOURCE_BRANCH="${SOURCE_BRANCH:-}"
|
||||
COMMIT_HASH="${COMMIT_HASH:-}"
|
||||
DEPLOYMENT_ID="${DEPLOYMENT_ID:-}"
|
||||
case "${ACTION}" in DEPLOY|STATUS|UNINSTALL) ;; *) exit 1 ;; esac
|
||||
if [ -n "${SOURCE_BRANCH}" ]; then
|
||||
git check-ref-format --branch "${SOURCE_BRANCH}" >/dev/null
|
||||
fi
|
||||
if [ -n "${COMMIT_HASH}" ] && ! printf '%s' "${COMMIT_HASH}" | grep -Eq '^[0-9a-fA-F]{7,40}$'; then
|
||||
echo 'COMMIT_HASH 只能填写 7 到 40 位十六进制 Git commit hash。' >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "${DEPLOYMENT_ID}" ] && ! printf '%s' "${DEPLOYMENT_ID}" | grep -Eq '^preview-[0-9a-f]{16}$'; then
|
||||
echo 'DEPLOYMENT_ID 格式非法。' >&2
|
||||
exit 1
|
||||
fi
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('Checkout Requested Source') {
|
||||
when {
|
||||
expression { params.ACTION == 'DEPLOY' }
|
||||
}
|
||||
steps {
|
||||
dir('source') {
|
||||
script {
|
||||
def remoteConfig = [
|
||||
url: env.GIT_REMOTE_URL,
|
||||
credentialsId: env.GIT_REMOTE_CREDENTIAL_ID,
|
||||
refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}",
|
||||
]
|
||||
checkout([
|
||||
$class: 'GitSCM',
|
||||
branches: [[name: "*/${params.SOURCE_BRANCH}"]],
|
||||
doGenerateSubmoduleConfigurations: false,
|
||||
extensions: [
|
||||
[$class: 'CleanBeforeCheckout'],
|
||||
[$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true],
|
||||
],
|
||||
userRemoteConfigs: [remoteConfig],
|
||||
])
|
||||
}
|
||||
withCredentials([sshUserPrivateKey(credentialsId: env.GIT_REMOTE_CREDENTIAL_ID, keyFileVariable: 'GENARRATIVE_GIT_SSH_KEY')]) {
|
||||
sh '''
|
||||
set -euo pipefail
|
||||
SOURCE_BRANCH="${SOURCE_BRANCH:-}" \
|
||||
COMMIT_HASH="${COMMIT_HASH:-}" \
|
||||
GIT_REMOTE_URL="${GIT_REMOTE_URL:-}" \
|
||||
SOURCE_COMMIT_FILE=".jenkins-source-commit" \
|
||||
GENARRATIVE_JENKINS_REUSE_EXISTING_CHECKOUT="true" \
|
||||
GIT_SSH_COMMAND="ssh -i ${GENARRATIVE_GIT_SSH_KEY} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" \
|
||||
bash "${WORKSPACE}/scripts/jenkins-checkout-source.sh"
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Execute Preview Action') {
|
||||
steps {
|
||||
sh '''
|
||||
set -euo pipefail
|
||||
chmod +x scripts/jenkins-preview-deployer.sh
|
||||
SOURCE_DIR="${WORKSPACE}/source" \
|
||||
RESULT_FILE="${WORKSPACE}/preview-result.json" \
|
||||
DESCRIPTION_FILE="${WORKSPACE}/.jenkins-preview-description" \
|
||||
scripts/jenkins-preview-deployer.sh
|
||||
'''
|
||||
script {
|
||||
if (fileExists('.jenkins-preview-description')) {
|
||||
currentBuild.description = readFile('.jenkins-preview-description').trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
always {
|
||||
script {
|
||||
if (!fileExists('preview-result.json')) {
|
||||
writeFile file: 'preview-result.json', text: groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson([
|
||||
schemaVersion: 1,
|
||||
action: params.ACTION ?: 'UNKNOWN',
|
||||
deploymentId: params.DEPLOYMENT_ID ?: null,
|
||||
projectName: params.DEPLOYMENT_ID ? "genarrative-${params.DEPLOYMENT_ID}" : null,
|
||||
branch: params.SOURCE_BRANCH ?: null,
|
||||
requestedCommit: params.COMMIT_HASH ?: null,
|
||||
resolvedCommit: null,
|
||||
status: 'failed',
|
||||
health: 'unknown',
|
||||
webUrl: null,
|
||||
message: 'Jenkins 在生成部署结果前失败,请查看构建日志。',
|
||||
updatedAt: new Date().format("yyyy-MM-dd'T'HH:mm:ssXXX"),
|
||||
])) + '\n'
|
||||
}
|
||||
}
|
||||
archiveArtifacts artifacts: 'preview-result.json', allowEmptyArchive: false, fingerprint: true
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user