Merge remote-tracking branch 'origin/master' into feat/pixel_art
This commit is contained in:
@@ -24,8 +24,10 @@ 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',
|
||||
id: null,
|
||||
branch: 'master',
|
||||
status: 'queued',
|
||||
health: 'pending',
|
||||
@@ -33,7 +35,7 @@ beforeEach(() => {
|
||||
updatedAt: '2026-08-15T00:00:00Z',
|
||||
});
|
||||
vi.mocked(api.uninstallDeployment).mockResolvedValue({
|
||||
id: 'preview-1',
|
||||
id: '1',
|
||||
branch: 'master',
|
||||
status: 'uninstalling',
|
||||
health: 'pending',
|
||||
@@ -81,7 +83,7 @@ test('submits a branch with an optional commit hash', async () => {
|
||||
test('shows health and web url, then confirms uninstall', async () => {
|
||||
vi.mocked(api.listDeployments).mockResolvedValue([
|
||||
{
|
||||
id: 'preview-2',
|
||||
id: '42',
|
||||
branch: 'feature/demo',
|
||||
resolvedCommit: '1234567890abcdef',
|
||||
status: 'running',
|
||||
@@ -96,6 +98,7 @@ test('shows health and web url, then confirms uninstall', async () => {
|
||||
|
||||
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');
|
||||
@@ -105,6 +108,89 @@ test('shows health and web url, then confirms uninstall', async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认卸载' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.uninstallDeployment).toHaveBeenCalledWith('preview-2');
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -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<DeploymentStatus, string> = {
|
||||
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<SearchState<BranchRef>>(emptySearchState());
|
||||
const [commitSearch, setCommitSearch] =
|
||||
useState<SearchState<CommitRef>>(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<PreviewDeployment | null>(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')
|
||||
@@ -178,6 +289,9 @@ export function PreviewDeployerApp() {
|
||||
return;
|
||||
}
|
||||
const deployment = pendingUninstall;
|
||||
if (!deployment.id) {
|
||||
return;
|
||||
}
|
||||
setPendingUninstall(null);
|
||||
setUninstallingId(deployment.id);
|
||||
setError('');
|
||||
@@ -356,36 +470,166 @@ export function PreviewDeployerApp() {
|
||||
className="deploy-form"
|
||||
onSubmit={(event) => void handleSubmit(event)}
|
||||
>
|
||||
<label>
|
||||
<span>分支名</span>
|
||||
<div className="input-shell">
|
||||
<div className="search-field">
|
||||
<label htmlFor="branch-input">分支名</label>
|
||||
<div className="input-shell" role="presentation">
|
||||
<GitBranch size={17} />
|
||||
<input
|
||||
aria-activedescendant={
|
||||
branchSearch.open && branchActiveIndex >= 0
|
||||
? `branch-option-${branchActiveIndex}`
|
||||
: undefined
|
||||
}
|
||||
aria-autocomplete="list"
|
||||
aria-controls="branch-search-list"
|
||||
aria-expanded={branchSearch.open}
|
||||
aria-haspopup="listbox"
|
||||
aria-label="分支名"
|
||||
role="combobox"
|
||||
id="branch-input"
|
||||
autoComplete="off"
|
||||
disabled={submitting}
|
||||
maxLength={200}
|
||||
placeholder="master"
|
||||
value={branch}
|
||||
onChange={(event) => setBranch(event.target.value)}
|
||||
onChange={(event) => {
|
||||
setBranchSearchEnabled(true);
|
||||
setBranch(event.target.value);
|
||||
if (commitHash) {
|
||||
setCommitHash('');
|
||||
setCommitSearchEnabled(false);
|
||||
}
|
||||
}}
|
||||
onFocus={() => {
|
||||
setBranchSearchEnabled(true);
|
||||
setBranchSearch((current) => ({ ...current, open: true }));
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
handleSearchKeyDown(
|
||||
event,
|
||||
branchSearch,
|
||||
branchActiveIndex,
|
||||
(index) => setBranchActiveIndex(index),
|
||||
(item) => {
|
||||
setBranch(item.name);
|
||||
setCommitHash('');
|
||||
setBranchSearchEnabled(false);
|
||||
setCommitSearchEnabled(false);
|
||||
setBranchSearch((current) => ({
|
||||
...current,
|
||||
open: false,
|
||||
}));
|
||||
},
|
||||
() =>
|
||||
setBranchSearch((current) => ({
|
||||
...current,
|
||||
open: false,
|
||||
})),
|
||||
);
|
||||
}}
|
||||
onBlur={() =>
|
||||
window.setTimeout(
|
||||
() =>
|
||||
setBranchSearch((current) => ({
|
||||
...current,
|
||||
open: false,
|
||||
})),
|
||||
120,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
<span>
|
||||
<SearchResults
|
||||
id="branch-search-list"
|
||||
type="branch"
|
||||
state={branchSearch}
|
||||
onSelect={(item) => {
|
||||
setBranch(item.name);
|
||||
setCommitHash('');
|
||||
setBranchSearchEnabled(false);
|
||||
setCommitSearchEnabled(false);
|
||||
setBranchSearch((current) => ({ ...current, open: false }));
|
||||
}}
|
||||
activeIndex={branchActiveIndex}
|
||||
/>
|
||||
</div>
|
||||
<div className="search-field">
|
||||
<label htmlFor="commit-input">
|
||||
Commit Hash <small>可选</small>
|
||||
</span>
|
||||
<div className="input-shell">
|
||||
</label>
|
||||
<div className="input-shell" role="presentation">
|
||||
<Hash size={17} />
|
||||
<input
|
||||
aria-activedescendant={
|
||||
commitSearch.open && commitActiveIndex >= 0
|
||||
? `commit-option-${commitActiveIndex}`
|
||||
: undefined
|
||||
}
|
||||
aria-autocomplete="list"
|
||||
aria-controls="commit-search-list"
|
||||
aria-expanded={commitSearch.open}
|
||||
aria-haspopup="listbox"
|
||||
aria-label="Commit Hash"
|
||||
role="combobox"
|
||||
id="commit-input"
|
||||
autoComplete="off"
|
||||
disabled={submitting}
|
||||
maxLength={40}
|
||||
placeholder="留空则构建分支最新提交"
|
||||
value={commitHash}
|
||||
onChange={(event) => setCommitHash(event.target.value)}
|
||||
onChange={(event) => {
|
||||
setCommitSearchEnabled(true);
|
||||
setCommitHash(event.target.value);
|
||||
}}
|
||||
onFocus={() => {
|
||||
setCommitSearchEnabled(true);
|
||||
setCommitSearch((current) => ({ ...current, open: true }));
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
handleSearchKeyDown(
|
||||
event,
|
||||
commitSearch,
|
||||
commitActiveIndex,
|
||||
(index) => setCommitActiveIndex(index),
|
||||
(item) => {
|
||||
setCommitHash(item.commitHash);
|
||||
setCommitSearchEnabled(false);
|
||||
setCommitSearch((current) => ({
|
||||
...current,
|
||||
open: false,
|
||||
}));
|
||||
},
|
||||
() =>
|
||||
setCommitSearch((current) => ({
|
||||
...current,
|
||||
open: false,
|
||||
})),
|
||||
);
|
||||
}}
|
||||
onBlur={() =>
|
||||
window.setTimeout(
|
||||
() =>
|
||||
setCommitSearch((current) => ({
|
||||
...current,
|
||||
open: false,
|
||||
})),
|
||||
120,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<SearchResults
|
||||
id="commit-search-list"
|
||||
type="commit"
|
||||
state={commitSearch}
|
||||
onSelect={(item) => {
|
||||
setCommitHash(item.commitHash);
|
||||
setCommitSearchEnabled(false);
|
||||
setCommitSearch((current) => ({ ...current, open: false }));
|
||||
}}
|
||||
activeIndex={commitActiveIndex}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="primary-button"
|
||||
type="submit"
|
||||
@@ -435,7 +679,10 @@ export function PreviewDeployerApp() {
|
||||
<div className="deployment-list">
|
||||
{deployments.map((deployment) => (
|
||||
<DeploymentCard
|
||||
key={deployment.id}
|
||||
key={
|
||||
deployment.id ??
|
||||
`${deployment.branch}-${deployment.createdAt}`
|
||||
}
|
||||
deployment={deployment}
|
||||
uninstalling={uninstallingId === deployment.id}
|
||||
onUninstall={() => setPendingUninstall(deployment)}
|
||||
@@ -533,6 +780,9 @@ function DeploymentCard({
|
||||
{deployment.webPort ? (
|
||||
<span className="badge port-badge">端口 {deployment.webPort}</span>
|
||||
) : null}
|
||||
<span className="badge record-badge">
|
||||
构建 #{deployment.id || '待分配'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -570,7 +820,7 @@ function DeploymentCard({
|
||||
构建详情 <ExternalLink size={13} />
|
||||
</a>
|
||||
) : null}
|
||||
{canUninstall ? (
|
||||
{canUninstall && deployment.id ? (
|
||||
<button
|
||||
className="icon-danger-button"
|
||||
type="button"
|
||||
@@ -623,6 +873,121 @@ function Summary({
|
||||
);
|
||||
}
|
||||
|
||||
type SearchState<T> = {
|
||||
items: T[];
|
||||
loading: boolean;
|
||||
open: boolean;
|
||||
error: string;
|
||||
};
|
||||
|
||||
function emptySearchState<T>(): SearchState<T> {
|
||||
return { items: [], loading: false, open: false, error: '' };
|
||||
}
|
||||
|
||||
function handleSearchKeyDown<T>(
|
||||
event: ReactKeyboardEvent<HTMLInputElement>,
|
||||
state: SearchState<T>,
|
||||
activeIndex: number,
|
||||
setActiveIndex: (index: number) => void,
|
||||
onSelect: (item: T) => void,
|
||||
onClose: () => void,
|
||||
) {
|
||||
if (!state.open || (!state.items.length && event.key !== 'Escape')) {
|
||||
return;
|
||||
}
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
setActiveIndex((activeIndex + 1) % state.items.length);
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
setActiveIndex(activeIndex <= 0 ? state.items.length - 1 : activeIndex - 1);
|
||||
} else if (event.key === 'Enter' && activeIndex >= 0) {
|
||||
event.preventDefault();
|
||||
const item = state.items[activeIndex];
|
||||
if (item) onSelect(item);
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setActiveIndex(-1);
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function SearchResults({
|
||||
id,
|
||||
type,
|
||||
state,
|
||||
activeIndex,
|
||||
onSelect,
|
||||
}:
|
||||
| {
|
||||
id: string;
|
||||
type: 'branch';
|
||||
state: SearchState<BranchRef>;
|
||||
activeIndex: number;
|
||||
onSelect: (item: BranchRef) => void;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
type: 'commit';
|
||||
state: SearchState<CommitRef>;
|
||||
activeIndex: number;
|
||||
onSelect: (item: CommitRef) => void;
|
||||
}) {
|
||||
if (!state.open) return null;
|
||||
return (
|
||||
<div className="search-results" id={id} role="listbox">
|
||||
{state.loading ? (
|
||||
<div className="search-state">
|
||||
<LoaderCircle className="spin" size={15} />
|
||||
正在搜索
|
||||
</div>
|
||||
) : state.error ? (
|
||||
<div className="search-state search-state-error">{state.error}</div>
|
||||
) : state.items.length === 0 ? (
|
||||
<div className="search-state">没有匹配结果</div>
|
||||
) : type === 'branch' ? (
|
||||
state.items.map((item, index) => (
|
||||
<button
|
||||
aria-selected={index === activeIndex}
|
||||
className={`search-option${index === activeIndex ? ' active' : ''}`}
|
||||
id={`branch-option-${index}`}
|
||||
key={item.name}
|
||||
role="option"
|
||||
type="button"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => onSelect(item)}
|
||||
>
|
||||
<GitBranch size={15} />
|
||||
<span>{item.name}</span>
|
||||
{item.commitHash ? (
|
||||
<small>{shortCommit(item.commitHash)}</small>
|
||||
) : null}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
state.items.map((item, index) => (
|
||||
<button
|
||||
aria-selected={index === activeIndex}
|
||||
className={`search-option${index === activeIndex ? ' active' : ''}`}
|
||||
id={`commit-option-${index}`}
|
||||
key={item.commitHash}
|
||||
role="option"
|
||||
type="button"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => onSelect(item)}
|
||||
>
|
||||
<Hash size={15} />
|
||||
<span>
|
||||
<strong>{item.shortHash || shortCommit(item.commitHash)}</strong>
|
||||
{item.subject ? <small>{item.subject}</small> : null}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isActive(status: DeploymentStatus) {
|
||||
return ['queued', 'building', 'deploying', 'uninstalling'].includes(status);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
deleteSession,
|
||||
getSession,
|
||||
listDeployments,
|
||||
searchBranches,
|
||||
searchCommits,
|
||||
uninstallDeployment,
|
||||
} from './api';
|
||||
|
||||
@@ -112,3 +114,45 @@ test('submits only branch and optional commit to fixed endpoints', async () => {
|
||||
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' }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -16,6 +16,17 @@ 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,
|
||||
@@ -41,6 +52,26 @@ export async function listDeployments(signal?: AbortSignal) {
|
||||
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',
|
||||
|
||||
@@ -190,14 +190,77 @@ a {
|
||||
padding: 22px;
|
||||
box-shadow: 0 12px 32px rgba(28, 37, 51, 0.055);
|
||||
}
|
||||
.deploy-form label {
|
||||
.deploy-form label,
|
||||
.search-field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
color: #39465a;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.deploy-form label small {
|
||||
.search-field {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.search-results {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
top: calc(100% - 1px);
|
||||
right: 0;
|
||||
left: 0;
|
||||
display: grid;
|
||||
max-height: 230px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #cfd7e3;
|
||||
border-radius: 0 0 9px 9px;
|
||||
background: #fff;
|
||||
box-shadow: 0 10px 25px rgba(28, 37, 51, 0.13);
|
||||
}
|
||||
.search-option {
|
||||
display: flex;
|
||||
min-height: 39px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 0;
|
||||
color: #344054;
|
||||
background: #fff;
|
||||
padding: 7px 11px;
|
||||
text-align: left;
|
||||
}
|
||||
.search-option:hover,
|
||||
.search-option.active {
|
||||
background: #eff6ff;
|
||||
}
|
||||
.search-option > span {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 1px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.search-option small {
|
||||
overflow: hidden;
|
||||
color: #8792a2;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 10px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.search-state {
|
||||
display: flex;
|
||||
min-height: 39px;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: #8792a2;
|
||||
padding: 0 11px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.search-state-error {
|
||||
color: #b42318;
|
||||
}
|
||||
.deploy-form label small,
|
||||
.search-field label small {
|
||||
color: #8994a5;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -386,6 +449,11 @@ a {
|
||||
background: #f1f5f9;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
.record-badge {
|
||||
color: #475569;
|
||||
background: #f8fafc;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
.badge-running {
|
||||
color: #14734a;
|
||||
background: #e9f9f0;
|
||||
|
||||
@@ -11,7 +11,7 @@ export type DeploymentStatus =
|
||||
export type DeploymentHealth = 'pending' | 'healthy' | 'unhealthy' | 'unknown';
|
||||
|
||||
export interface PreviewDeployment {
|
||||
id: string;
|
||||
id?: string | null;
|
||||
branch: string;
|
||||
commitHash?: string | null;
|
||||
resolvedCommit?: string | null;
|
||||
|
||||
+6
@@ -1,8 +1,14 @@
|
||||
# 仅部署在本机内网 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
|
||||
|
||||
@@ -14168,3 +14168,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-17 预览发布记录使用 Jenkins 构建编号并有限保留
|
||||
|
||||
- 决策:内部稳定 `deploymentId` 继续绑定分支、Compose project 和端口租约;页面/API 记录 ID 在 Jenkins 分配执行器后改为构建编号,排队阶段为“待分配”。卸载通过构建编号找到内部实例,再向固定 Job 传内部 ID。
|
||||
- 清理:失败或取消且不存在可卸载实例的记录保留 7 天;成功卸载的内部审计记录保留 30 天;仍可卸载的失败记录永久保留到人工卸载。服务启动、读取列表和创建部署时执行清理并原子落盘。
|
||||
- 链接:服务内部仍通过 Jenkins loopback 轮询;只向浏览器返回由 `GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_PUBLIC_BASE_URL` 构造的局域网构建详情地址,禁止回传 loopback URL。
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
## 部署身份与端口
|
||||
|
||||
`deploymentId` 由规范化分支名与分支名摘要确定,同一分支稳定得到同一 ID;commit 不进入 ID,因此同一分支重新构建或指定不同 commit 时复用同一预览实例和 Web 端口。
|
||||
预览实例 ID(`deploymentId`)由规范化分支名与分支名摘要确定,只在控制服务内部和 Jenkins 参数中使用;commit 不进入该 ID,因此同一分支重新构建或指定不同 commit 时复用同一预览实例和 Web 端口。页面发布记录的公开 ID 使用 Jenkins 构建编号:排队期间尚未分配编号,显示“待分配”,构建开始后立即显示真实编号。
|
||||
|
||||
Web 端口池固定为 `8400..8499`:
|
||||
|
||||
@@ -86,13 +86,17 @@ SpacetimeDB 2.7 CLI 发布到受控 Compose 网络地址时固定使用 `--yes=r
|
||||
- `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`。
|
||||
|
||||
发布记录卡片直接展示后端校验后的 `webPort`。卸载成功的 `stopped` 记录仍保留在服务端持久状态中用于审计和所有权校验,但列表 API 不再返回,页面刷新后立即从发布记录中消失。
|
||||
发布记录卡片直接展示 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`。
|
||||
|
||||
@@ -103,6 +107,7 @@ Jenkins 在构建完成、归档 artifact 和更新 REST 状态之间可能短
|
||||
- 服务端缺少控制面访问口令或 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 的发布和卸载串行执行;重复请求必须幂等或明确返回冲突。
|
||||
|
||||
@@ -675,7 +675,7 @@ 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`。
|
||||
多人内网预览入口固定为 `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 排空另行验证。
|
||||
|
||||
@@ -21,6 +21,10 @@ const systemd = readFileSync(
|
||||
'deploy/systemd/genarrative-preview-deployer.service',
|
||||
'utf8',
|
||||
);
|
||||
const environmentExample = readFileSync(
|
||||
'deploy/env/preview-deployer.env.example',
|
||||
'utf8',
|
||||
);
|
||||
const server = readFileSync(
|
||||
'server-rs/crates/preview-deployer-server/src/lib.rs',
|
||||
'utf8',
|
||||
@@ -162,6 +166,21 @@ assertIncludes(
|
||||
'.nest_service("/build/assets", ServeDir::new(static_dir.join("assets")))',
|
||||
'控制服务必须原生托管 /build 子路径,不能依赖 Nginx 隐式改写。',
|
||||
);
|
||||
assertIncludes(
|
||||
server,
|
||||
'FAILED_RECORD_TTL_SECS',
|
||||
'控制服务必须清理过期且不可卸载的失败/取消记录。',
|
||||
);
|
||||
assertIncludes(
|
||||
server,
|
||||
'STOPPED_RECORD_TTL_SECS',
|
||||
'控制服务必须为已卸载记录配置有限审计保留期。',
|
||||
);
|
||||
assertIncludes(
|
||||
environmentExample,
|
||||
'GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_PUBLIC_BASE_URL=http://192.168.35.82:8080/jenkins/',
|
||||
'构建详情必须使用局域网 Jenkins 地址而非 loopback。',
|
||||
);
|
||||
assertIncludes(
|
||||
jobConfig,
|
||||
'<scriptPath>jenkins/Jenkinsfile.preview-deployer</scriptPath>',
|
||||
|
||||
@@ -10,7 +10,7 @@ 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"] }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "time", "sync", "signal", "process", "fs", "io-util"] }
|
||||
tower-http = { workspace = true, features = ["fs", "trace"] }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true, features = ["env-filter"] }
|
||||
|
||||
@@ -9,8 +9,11 @@ pub struct Config {
|
||||
pub bind_address: String,
|
||||
pub jenkins_root_url: Url,
|
||||
pub jenkins_base_url: Url,
|
||||
pub jenkins_public_base_url: Url,
|
||||
pub jenkins_username: String,
|
||||
pub jenkins_api_token: String,
|
||||
pub git_remote_url: String,
|
||||
pub git_ssh_command: Option<String>,
|
||||
pub access_token: String,
|
||||
pub allowed_hosts: Vec<String>,
|
||||
pub allowed_origins: Vec<String>,
|
||||
@@ -28,6 +31,7 @@ impl fmt::Debug for 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_public_base_url", &self.jenkins_public_base_url)
|
||||
.field(
|
||||
"jenkins_username_configured",
|
||||
&!self.jenkins_username.is_empty(),
|
||||
@@ -36,6 +40,11 @@ impl fmt::Debug for Config {
|
||||
"jenkins_api_token_configured",
|
||||
&!self.jenkins_api_token.is_empty(),
|
||||
)
|
||||
.field("git_remote_url", &self.git_remote_url)
|
||||
.field(
|
||||
"git_ssh_command_configured",
|
||||
&self.git_ssh_command.is_some(),
|
||||
)
|
||||
.field("access_token_configured", &!self.access_token.is_empty())
|
||||
.field("allowed_hosts", &self.allowed_hosts)
|
||||
.field("allowed_origins", &self.allowed_origins)
|
||||
@@ -72,7 +81,28 @@ impl Config {
|
||||
let jenkins_base_url = jenkins_root_url
|
||||
.join(JOB_PATH)
|
||||
.map_err(|_| "无法构造固定 Jenkins Job URL".to_string())?;
|
||||
|
||||
let mut jenkins_public_root_url = Url::parse(&required(
|
||||
"GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_PUBLIC_BASE_URL",
|
||||
)?)
|
||||
.map_err(|_| {
|
||||
"GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_PUBLIC_BASE_URL 不是有效 URL".to_string()
|
||||
})?;
|
||||
if !matches!(jenkins_public_root_url.scheme(), "http" | "https")
|
||||
|| jenkins_public_root_url.host_str().is_none()
|
||||
|| !jenkins_public_root_url.username().is_empty()
|
||||
|| jenkins_public_root_url.password().is_some()
|
||||
{
|
||||
return Err("Jenkins public base URL 必须是无凭据的 http/https 绝对 URL".to_string());
|
||||
}
|
||||
jenkins_public_root_url.set_query(None);
|
||||
jenkins_public_root_url.set_fragment(None);
|
||||
if !jenkins_public_root_url.path().ends_with('/') {
|
||||
let path = format!("{}/", jenkins_public_root_url.path());
|
||||
jenkins_public_root_url.set_path(&path);
|
||||
}
|
||||
let jenkins_public_base_url = jenkins_public_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());
|
||||
@@ -118,8 +148,16 @@ impl Config {
|
||||
bind_address,
|
||||
jenkins_root_url,
|
||||
jenkins_base_url,
|
||||
jenkins_public_base_url,
|
||||
jenkins_username: required("GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_USERNAME")?,
|
||||
jenkins_api_token: required("GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_API_TOKEN")?,
|
||||
git_remote_url: validate_git_remote_url(&required(
|
||||
"GENARRATIVE_PREVIEW_DEPLOYER_GIT_REMOTE_URL",
|
||||
)?)?,
|
||||
git_ssh_command: env::var("GENARRATIVE_PREVIEW_DEPLOYER_GIT_SSH_COMMAND")
|
||||
.ok()
|
||||
.map(|value| validate_git_ssh_command(value.trim()))
|
||||
.transpose()?,
|
||||
access_token,
|
||||
allowed_hosts,
|
||||
allowed_origins,
|
||||
@@ -132,6 +170,31 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_git_ssh_command(value: &str) -> Result<String, String> {
|
||||
const TRUSTED_COMMAND: &str = "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";
|
||||
if value != TRUSTED_COMMAND {
|
||||
return Err(
|
||||
"GENARRATIVE_PREVIEW_DEPLOYER_GIT_SSH_COMMAND 必须使用固定只读密钥和严格主机校验参数"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
Ok(value.to_string())
|
||||
}
|
||||
|
||||
fn validate_git_remote_url(value: &str) -> Result<String, String> {
|
||||
const TRUSTED_REMOTE: &str = "ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git";
|
||||
#[cfg(test)]
|
||||
if value.starts_with("test://") {
|
||||
return Ok(value.to_string());
|
||||
}
|
||||
if value != TRUSTED_REMOTE {
|
||||
return Err(format!(
|
||||
"GENARRATIVE_PREVIEW_DEPLOYER_GIT_REMOTE_URL 只允许固定内网仓库 {TRUSTED_REMOTE}"
|
||||
));
|
||||
}
|
||||
Ok(value.to_string())
|
||||
}
|
||||
|
||||
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(
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
use std::{path::PathBuf, process::Stdio, sync::Arc, time::Duration};
|
||||
|
||||
use serde::Serialize;
|
||||
use tokio::{process::Command, sync::Mutex};
|
||||
|
||||
const MAX_RESULTS: usize = 20;
|
||||
const COMMAND_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct GitRepository {
|
||||
remote_url: String,
|
||||
ssh_command: Option<String>,
|
||||
cache_dir: PathBuf,
|
||||
lock: Arc<Mutex<()>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BranchMatch {
|
||||
pub name: String,
|
||||
pub commit_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommitMatch {
|
||||
pub commit_hash: String,
|
||||
pub short_hash: String,
|
||||
pub subject: String,
|
||||
}
|
||||
|
||||
impl GitRepository {
|
||||
pub fn new(remote_url: String, ssh_command: Option<String>, cache_dir: PathBuf) -> Self {
|
||||
Self {
|
||||
remote_url,
|
||||
ssh_command,
|
||||
cache_dir,
|
||||
lock: Arc::new(Mutex::new(())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn search_branches(&self, query: &str) -> Result<Vec<BranchMatch>, String> {
|
||||
#[cfg(test)]
|
||||
if self.remote_url == "test://preview-repository" {
|
||||
let candidates = [
|
||||
(
|
||||
"feature/preview-ui",
|
||||
"0123456789abcdef0123456789abcdef01234567",
|
||||
),
|
||||
("feature/busy", "89abcdef0123456789abcdef0123456789abcdef"),
|
||||
];
|
||||
return Ok(candidates
|
||||
.into_iter()
|
||||
.filter(|(name, _)| name.contains(query))
|
||||
.map(|(name, commit_hash)| BranchMatch {
|
||||
name: name.to_string(),
|
||||
commit_hash: commit_hash.to_string(),
|
||||
})
|
||||
.collect());
|
||||
}
|
||||
let output = self
|
||||
.run_remote(&["ls-remote", "--heads", &self.remote_url])
|
||||
.await?;
|
||||
let query = query.to_ascii_lowercase();
|
||||
let mut matches: Vec<_> = output
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let (commit_hash, reference) = line.split_once('\t')?;
|
||||
let name = reference.strip_prefix("refs/heads/")?;
|
||||
(name.to_ascii_lowercase().contains(&query)
|
||||
&& super::validate_branch(name).is_ok()
|
||||
&& super::validate_commit(commit_hash).is_ok())
|
||||
.then(|| BranchMatch {
|
||||
name: name.to_string(),
|
||||
commit_hash: commit_hash.to_ascii_lowercase(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
matches.sort_by(|left, right| {
|
||||
branch_rank(&left.name, query.as_str())
|
||||
.cmp(&branch_rank(&right.name, query.as_str()))
|
||||
.then_with(|| left.name.cmp(&right.name))
|
||||
});
|
||||
matches.truncate(MAX_RESULTS);
|
||||
Ok(matches)
|
||||
}
|
||||
|
||||
pub async fn branch_exists(&self, branch: &str) -> Result<bool, String> {
|
||||
#[cfg(test)]
|
||||
if self.remote_url == "test://preview-repository" {
|
||||
return Ok(matches!(branch, "feature/preview-ui" | "feature/busy"));
|
||||
}
|
||||
let reference = format!("refs/heads/{branch}");
|
||||
let output = self
|
||||
.run_remote(&["ls-remote", "--heads", &self.remote_url, &reference])
|
||||
.await?;
|
||||
Ok(output.lines().any(|line| {
|
||||
line.split_once('\t')
|
||||
.is_some_and(|(_, returned)| returned == reference)
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn search_commits(
|
||||
&self,
|
||||
branch: &str,
|
||||
query: &str,
|
||||
) -> Result<Vec<CommitMatch>, String> {
|
||||
#[cfg(test)]
|
||||
if self.remote_url == "test://preview-repository" {
|
||||
if !self.branch_exists(branch).await? {
|
||||
return Err("test branch missing".to_string());
|
||||
}
|
||||
return Ok(vec![CommitMatch {
|
||||
commit_hash: "0123456789abcdef0123456789abcdef01234567".to_string(),
|
||||
short_hash: "0123456".to_string(),
|
||||
subject: "test preview commit".to_string(),
|
||||
}]);
|
||||
}
|
||||
let _guard = self.lock.lock().await;
|
||||
self.fetch_branch(branch).await?;
|
||||
let branch_ref = format!("refs/remotes/origin/{branch}");
|
||||
let output = self
|
||||
.run_cached(&[
|
||||
"log",
|
||||
"--format=%H%x09%h%x09%s",
|
||||
"--max-count=500",
|
||||
&branch_ref,
|
||||
])
|
||||
.await?;
|
||||
let query = query.to_ascii_lowercase();
|
||||
let mut matches: Vec<_> = output
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let mut parts = line.splitn(3, '\t');
|
||||
let hash = parts.next()?;
|
||||
let short_hash = parts.next()?;
|
||||
let subject = parts.next().unwrap_or_default();
|
||||
(query.is_empty()
|
||||
|| hash.to_ascii_lowercase().starts_with(&query)
|
||||
|| subject.to_ascii_lowercase().contains(&query))
|
||||
.then(|| CommitMatch {
|
||||
commit_hash: hash.to_ascii_lowercase(),
|
||||
short_hash: short_hash.to_ascii_lowercase(),
|
||||
subject: subject.chars().take(200).collect(),
|
||||
})
|
||||
})
|
||||
.take(MAX_RESULTS)
|
||||
.collect();
|
||||
matches.truncate(MAX_RESULTS);
|
||||
Ok(matches)
|
||||
}
|
||||
|
||||
pub async fn commit_belongs_to_branch(
|
||||
&self,
|
||||
branch: &str,
|
||||
commit: &str,
|
||||
) -> Result<bool, String> {
|
||||
#[cfg(test)]
|
||||
if self.remote_url == "test://preview-repository" {
|
||||
return Ok(branch == "feature/preview-ui" && commit == "abcdef1");
|
||||
}
|
||||
let _guard = self.lock.lock().await;
|
||||
self.fetch_branch(branch).await?;
|
||||
let branch_ref = format!("refs/remotes/origin/{branch}");
|
||||
let resolved = self
|
||||
.run_cached_status(&["rev-parse", "--verify", &format!("{commit}^{{commit}}")])
|
||||
.await?;
|
||||
if !resolved {
|
||||
return Ok(false);
|
||||
}
|
||||
self.run_cached_status(&["merge-base", "--is-ancestor", commit, &branch_ref])
|
||||
.await
|
||||
}
|
||||
|
||||
async fn fetch_branch(&self, branch: &str) -> Result<(), String> {
|
||||
self.ensure_cache().await?;
|
||||
let refspec = format!("+refs/heads/{branch}:refs/remotes/origin/{branch}");
|
||||
let status = self
|
||||
.command()
|
||||
.arg("-C")
|
||||
.arg(&self.cache_dir)
|
||||
.args(["fetch", "--no-tags", "--prune", "origin", &refspec])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
let status = tokio::time::timeout(COMMAND_TIMEOUT, status)
|
||||
.await
|
||||
.map_err(|_| "Git 分支同步超时".to_string())?
|
||||
.map_err(|error| format!("无法执行 Git 分支同步: {error}"))?;
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("无法从固定仓库同步目标分支".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_cache(&self) -> Result<(), String> {
|
||||
if !self.cache_dir.exists() {
|
||||
tokio::fs::create_dir_all(&self.cache_dir)
|
||||
.await
|
||||
.map_err(|error| format!("无法创建 Git 查询缓存: {error}"))?;
|
||||
self.run_cached(&["init", "--bare"]).await?;
|
||||
}
|
||||
let metadata = tokio::fs::symlink_metadata(&self.cache_dir)
|
||||
.await
|
||||
.map_err(|error| format!("无法读取 Git 查询缓存: {error}"))?;
|
||||
if !metadata.is_dir() || metadata.file_type().is_symlink() {
|
||||
return Err("Git 查询缓存必须是普通目录且不能是符号链接".to_string());
|
||||
}
|
||||
if !self
|
||||
.run_cached_status(&["remote", "get-url", "origin"])
|
||||
.await?
|
||||
{
|
||||
self.run_cached(&["remote", "add", "origin", &self.remote_url])
|
||||
.await?;
|
||||
} else {
|
||||
let current = self.run_cached(&["remote", "get-url", "origin"]).await?;
|
||||
if current.trim() != self.remote_url {
|
||||
return Err("Git 查询缓存的固定远端不匹配".to_string());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_remote(&self, args: &[&str]) -> Result<String, String> {
|
||||
self.run(self.command().args(args)).await
|
||||
}
|
||||
|
||||
async fn run_cached(&self, args: &[&str]) -> Result<String, String> {
|
||||
self.run(self.command().arg("-C").arg(&self.cache_dir).args(args))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn run_cached_status(&self, args: &[&str]) -> Result<bool, String> {
|
||||
let status = self
|
||||
.command()
|
||||
.arg("-C")
|
||||
.arg(&self.cache_dir)
|
||||
.args(args)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
Ok(tokio::time::timeout(COMMAND_TIMEOUT, status)
|
||||
.await
|
||||
.map_err(|_| "Git 查询超时".to_string())?
|
||||
.map_err(|error| format!("无法执行 Git 查询: {error}"))?
|
||||
.success())
|
||||
}
|
||||
|
||||
fn command(&self) -> Command {
|
||||
let mut command = Command::new("git");
|
||||
command
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1");
|
||||
if let Some(value) = &self.ssh_command {
|
||||
command.env("GIT_SSH_COMMAND", value);
|
||||
}
|
||||
command
|
||||
}
|
||||
|
||||
async fn run(&self, command: &mut Command) -> Result<String, String> {
|
||||
let output = tokio::time::timeout(COMMAND_TIMEOUT, command.output())
|
||||
.await
|
||||
.map_err(|_| "Git 查询超时".to_string())?
|
||||
.map_err(|error| format!("无法执行 Git 查询: {error}"))?;
|
||||
if !output.status.success() {
|
||||
return Err("固定 Git 仓库查询失败".to_string());
|
||||
}
|
||||
String::from_utf8(output.stdout).map_err(|_| "Git 查询返回了无效文本".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn branch_rank(name: &str, query: &str) -> u8 {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
if lower == query {
|
||||
0
|
||||
} else if lower.starts_with(query) {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ pub struct JenkinsClient {
|
||||
http: Client,
|
||||
root_url: Url,
|
||||
job_url: Url,
|
||||
public_job_url: Url,
|
||||
username: String,
|
||||
api_token: String,
|
||||
poll_interval: Duration,
|
||||
@@ -32,6 +33,11 @@ pub struct BuildReference {
|
||||
queue_url: Url,
|
||||
}
|
||||
|
||||
pub struct BuildStarted {
|
||||
pub number: u64,
|
||||
pub public_url: String,
|
||||
}
|
||||
|
||||
impl BuildReference {
|
||||
pub fn as_str(&self) -> &str {
|
||||
self.queue_url.as_str()
|
||||
@@ -98,6 +104,7 @@ impl JenkinsClient {
|
||||
http,
|
||||
root_url: config.jenkins_root_url.clone(),
|
||||
job_url: config.jenkins_base_url.clone(),
|
||||
public_job_url: config.jenkins_public_base_url.clone(),
|
||||
username: config.jenkins_username.clone(),
|
||||
api_token: config.jenkins_api_token.clone(),
|
||||
poll_interval: config.poll_interval,
|
||||
@@ -200,7 +207,7 @@ impl JenkinsClient {
|
||||
mut on_build: F,
|
||||
) -> Result<JenkinsOutcome, String>
|
||||
where
|
||||
F: FnMut(&str) -> Fut,
|
||||
F: FnMut(BuildStarted) -> Fut,
|
||||
Fut: Future<Output = ()>,
|
||||
{
|
||||
let build_url = loop {
|
||||
@@ -226,7 +233,13 @@ impl JenkinsClient {
|
||||
tokio::time::sleep(self.poll_interval).await;
|
||||
};
|
||||
|
||||
on_build(build_url.as_str()).await;
|
||||
let build_number = parse_build_number(&build_url, &self.job_url)?;
|
||||
let public_url = self.public_build_url(&build_url)?;
|
||||
on_build(BuildStarted {
|
||||
number: build_number,
|
||||
public_url: public_url.to_string(),
|
||||
})
|
||||
.await;
|
||||
let successful = loop {
|
||||
let mut state_url = build_url
|
||||
.join("api/json")
|
||||
@@ -331,4 +344,35 @@ impl JenkinsClient {
|
||||
Err("Jenkins 返回了非受信源 URL".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn public_build_url(&self, internal: &Url) -> Result<Url, String> {
|
||||
self.ensure_same_origin(internal)?;
|
||||
let relative = internal
|
||||
.path()
|
||||
.strip_prefix(self.job_url.path())
|
||||
.ok_or_else(|| "Jenkins build URL 路径无效".to_string())?;
|
||||
let mut public = self
|
||||
.public_job_url
|
||||
.join(relative)
|
||||
.map_err(|_| "无法构造 Jenkins 内网构建详情地址".to_string())?;
|
||||
public.set_query(None);
|
||||
public.set_fragment(None);
|
||||
Ok(public)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_build_number(build_url: &Url, job_url: &Url) -> Result<u64, String> {
|
||||
let relative = build_url
|
||||
.path()
|
||||
.strip_prefix(job_url.path())
|
||||
.ok_or_else(|| "Jenkins build URL 路径无效".to_string())?
|
||||
.trim_end_matches('/');
|
||||
if relative.is_empty() || relative.contains('/') {
|
||||
return Err("Jenkins build URL 缺少构建编号".to_string());
|
||||
}
|
||||
relative
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.filter(|number| *number > 0)
|
||||
.ok_or_else(|| "Jenkins build 编号无效".to_string())
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod config;
|
||||
mod git_refs;
|
||||
mod jenkins;
|
||||
|
||||
use std::{
|
||||
@@ -10,7 +11,7 @@ use std::{
|
||||
use axum::extract::DefaultBodyLimit;
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, Request, State},
|
||||
extract::{Path, Query, Request, State},
|
||||
http::{HeaderMap, HeaderValue, StatusCode, header},
|
||||
middleware::{self, Next},
|
||||
response::{IntoResponse, Response},
|
||||
@@ -28,15 +29,19 @@ use url::Url;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub use config::Config;
|
||||
use git_refs::{BranchMatch, CommitMatch, GitRepository};
|
||||
use jenkins::{BuildAction, BuildReference, JenkinsClient, JenkinsOutcome};
|
||||
|
||||
const SESSION_COOKIE: &str = "genarrative_preview_session";
|
||||
const SESSION_TTL: Duration = Duration::from_secs(12 * 60 * 60);
|
||||
const FAILED_RECORD_TTL_SECS: u64 = 7 * 24 * 60 * 60;
|
||||
const STOPPED_RECORD_TTL_SECS: u64 = 30 * 24 * 60 * 60;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
config: Arc<Config>,
|
||||
jenkins: JenkinsClient,
|
||||
git: GitRepository,
|
||||
deployments: Arc<RwLock<HashMap<String, DeploymentRecord>>>,
|
||||
sessions: Arc<RwLock<HashMap<String, u64>>>,
|
||||
}
|
||||
@@ -45,9 +50,27 @@ impl AppState {
|
||||
pub fn new(config: Config) -> Result<Self, String> {
|
||||
let jenkins = JenkinsClient::new(&config)?;
|
||||
let deployments = load_deployments(&config)?;
|
||||
let git_cache_dir = config
|
||||
.state_file
|
||||
.parent()
|
||||
.expect("validated state path has a parent")
|
||||
.join(format!(
|
||||
".{}-git-ref-cache",
|
||||
config
|
||||
.state_file
|
||||
.file_stem()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("preview-deployer")
|
||||
));
|
||||
let git = GitRepository::new(
|
||||
config.git_remote_url.clone(),
|
||||
config.git_ssh_command.clone(),
|
||||
git_cache_dir,
|
||||
);
|
||||
Ok(Self {
|
||||
config: Arc::new(config),
|
||||
jenkins,
|
||||
git,
|
||||
deployments: Arc::new(RwLock::new(deployments)),
|
||||
sessions: Arc::new(RwLock::new(HashMap::new())),
|
||||
})
|
||||
@@ -79,7 +102,8 @@ pub enum HealthStatus {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Deployment {
|
||||
pub id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
pub branch: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub commit_hash: Option<String>,
|
||||
@@ -103,6 +127,8 @@ pub struct Deployment {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct DeploymentRecord {
|
||||
public: Deployment,
|
||||
#[serde(default)]
|
||||
instance_id: String,
|
||||
operation: Operation,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
queue_url: Option<String>,
|
||||
@@ -141,6 +167,33 @@ struct DeployRequest {
|
||||
commit_hash: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct BranchSearchQuery {
|
||||
#[serde(default)]
|
||||
q: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct CommitSearchQuery {
|
||||
branch: String,
|
||||
#[serde(default)]
|
||||
q: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct BranchSearchResponse {
|
||||
items: Vec<BranchMatch>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CommitSearchResponse {
|
||||
items: Vec<CommitMatch>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct DeploymentList {
|
||||
deployments: Vec<Deployment>,
|
||||
@@ -199,6 +252,22 @@ impl ApiError {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn source_ref(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::UNPROCESSABLE_ENTITY,
|
||||
code: "source_ref_invalid",
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn git_unavailable() -> Self {
|
||||
Self {
|
||||
status: StatusCode::BAD_GATEWAY,
|
||||
code: "git_unavailable",
|
||||
message: "暂时无法查询固定源码仓库".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
@@ -226,6 +295,8 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/preview-deployer/deployments",
|
||||
get(list_deployments).post(create_deployment),
|
||||
)
|
||||
.route("/api/preview-deployer/refs/branches", get(search_branches))
|
||||
.route("/api/preview-deployer/refs/commits", get(search_commits))
|
||||
.route(
|
||||
"/api/preview-deployer/deployments/{id}",
|
||||
get(get_deployment),
|
||||
@@ -382,6 +453,7 @@ async fn list_deployments(
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<DeploymentList>, ApiError> {
|
||||
require_session(&state, &headers).await?;
|
||||
prune_expired_records(&state).await;
|
||||
refresh_running_health(&state).await;
|
||||
let mut deployments: Vec<_> = state
|
||||
.deployments
|
||||
@@ -395,19 +467,59 @@ async fn list_deployments(
|
||||
Ok(Json(DeploymentList { deployments }))
|
||||
}
|
||||
|
||||
async fn search_branches(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<BranchSearchQuery>,
|
||||
) -> Result<Json<BranchSearchResponse>, ApiError> {
|
||||
require_session(&state, &headers).await?;
|
||||
let query = validate_search_query(&query.q)?;
|
||||
let branches = state.git.search_branches(&query).await.map_err(|cause| {
|
||||
warn!(error = %cause, "failed to search fixed Git remote branches");
|
||||
ApiError::git_unavailable()
|
||||
})?;
|
||||
Ok(Json(BranchSearchResponse { items: branches }))
|
||||
}
|
||||
|
||||
async fn search_commits(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<CommitSearchQuery>,
|
||||
) -> Result<Json<CommitSearchResponse>, ApiError> {
|
||||
require_session(&state, &headers).await?;
|
||||
let branch = validate_branch(&query.branch)?;
|
||||
let query = validate_commit_search_query(&query.q)?;
|
||||
if !state.git.branch_exists(&branch).await.map_err(|cause| {
|
||||
warn!(error = %cause, "failed to validate branch before commit search");
|
||||
ApiError::git_unavailable()
|
||||
})? {
|
||||
return Err(ApiError::source_ref("分支不存在"));
|
||||
}
|
||||
let commits = state
|
||||
.git
|
||||
.search_commits(&branch, &query)
|
||||
.await
|
||||
.map_err(|cause| {
|
||||
warn!(error = %cause, "failed to search fixed Git remote commits");
|
||||
ApiError::git_unavailable()
|
||||
})?;
|
||||
Ok(Json(CommitSearchResponse { items: commits }))
|
||||
}
|
||||
|
||||
async fn get_deployment(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<Deployment>, ApiError> {
|
||||
require_session(&state, &headers).await?;
|
||||
validate_deployment_id(&id)?;
|
||||
validate_record_id(&id)?;
|
||||
refresh_running_health(&state).await;
|
||||
let deployment = state
|
||||
.deployments
|
||||
.read()
|
||||
.await
|
||||
.get(&id)
|
||||
.values()
|
||||
.find(|record| record.public.id.as_deref() == Some(id.as_str()))
|
||||
.map(|record| record.public.clone())
|
||||
.ok_or_else(ApiError::not_found)?;
|
||||
Ok(Json(deployment))
|
||||
@@ -483,16 +595,37 @@ async fn create_deployment(
|
||||
Json(payload): Json<DeployRequest>,
|
||||
) -> Result<(StatusCode, Json<Deployment>), ApiError> {
|
||||
require_session(&state, &headers).await?;
|
||||
prune_expired_records(&state).await;
|
||||
let branch = validate_branch(&payload.branch)?;
|
||||
let commit_hash = payload
|
||||
.commit_hash
|
||||
.as_deref()
|
||||
.map(validate_commit)
|
||||
.transpose()?;
|
||||
if !state.git.branch_exists(&branch).await.map_err(|cause| {
|
||||
warn!(error = %cause, "failed to validate branch before deployment");
|
||||
ApiError::git_unavailable()
|
||||
})? {
|
||||
return Err(ApiError::source_ref("分支不存在,未触发 Jenkins 构建"));
|
||||
}
|
||||
if let Some(commit) = commit_hash.as_deref()
|
||||
&& !state
|
||||
.git
|
||||
.commit_belongs_to_branch(&branch, commit)
|
||||
.await
|
||||
.map_err(|cause| {
|
||||
warn!(error = %cause, "failed to validate commit before deployment");
|
||||
ApiError::git_unavailable()
|
||||
})?
|
||||
{
|
||||
return Err(ApiError::source_ref(
|
||||
"commit 不存在或不属于目标分支,未触发 Jenkins 构建",
|
||||
));
|
||||
}
|
||||
let id = derive_deployment_id(&branch);
|
||||
let now = unix_now();
|
||||
let deployment = Deployment {
|
||||
id: id.clone(),
|
||||
id: None,
|
||||
branch: branch.clone(),
|
||||
commit_hash: commit_hash.clone(),
|
||||
resolved_commit: None,
|
||||
@@ -523,6 +656,7 @@ async fn create_deployment(
|
||||
id.clone(),
|
||||
DeploymentRecord {
|
||||
public: deployment.clone(),
|
||||
instance_id: id.clone(),
|
||||
operation: Operation::Deploy,
|
||||
queue_url: None,
|
||||
},
|
||||
@@ -559,10 +693,13 @@ async fn uninstall_deployment(
|
||||
Path(id): Path<String>,
|
||||
) -> Result<(StatusCode, Json<Deployment>), ApiError> {
|
||||
require_session(&state, &headers).await?;
|
||||
validate_deployment_id(&id)?;
|
||||
let branch = {
|
||||
validate_record_id(&id)?;
|
||||
let (record_key, instance_id, branch) = {
|
||||
let mut deployments = state.deployments.write().await;
|
||||
let record = deployments.get_mut(&id).ok_or_else(ApiError::not_found)?;
|
||||
let (record_key, record) = deployments
|
||||
.iter_mut()
|
||||
.find(|(_, record)| record.public.id.as_deref() == Some(id.as_str()))
|
||||
.ok_or_else(ApiError::not_found)?;
|
||||
if matches!(
|
||||
record.public.status,
|
||||
DeploymentStatus::Queued
|
||||
@@ -582,7 +719,11 @@ async fn uninstall_deployment(
|
||||
record.public.message = Some("等待 Jenkins 卸载".to_string());
|
||||
record.operation = Operation::Uninstall;
|
||||
record.queue_url = None;
|
||||
record.public.branch.clone()
|
||||
(
|
||||
record_key.clone(),
|
||||
record.instance_id.clone(),
|
||||
record.public.branch.clone(),
|
||||
)
|
||||
};
|
||||
persist_deployments(&state)
|
||||
.await
|
||||
@@ -591,7 +732,7 @@ async fn uninstall_deployment(
|
||||
let reference = match state
|
||||
.jenkins
|
||||
.trigger(BuildAction::Uninstall {
|
||||
deployment_id: &id,
|
||||
deployment_id: &instance_id,
|
||||
branch: &branch,
|
||||
})
|
||||
.await
|
||||
@@ -599,7 +740,7 @@ async fn uninstall_deployment(
|
||||
Ok(reference) => reference,
|
||||
Err(cause) => {
|
||||
let mut deployments = state.deployments.write().await;
|
||||
if let Some(record) = deployments.get_mut(&id) {
|
||||
if let Some(record) = deployments.get_mut(&record_key) {
|
||||
record.public.status = DeploymentStatus::Failed;
|
||||
record.public.health = HealthStatus::Unknown;
|
||||
record.public.can_uninstall = true;
|
||||
@@ -619,11 +760,11 @@ async fn uninstall_deployment(
|
||||
.deployments
|
||||
.read()
|
||||
.await
|
||||
.get(&id)
|
||||
.get(&record_key)
|
||||
.expect("deployment exists")
|
||||
.public
|
||||
.clone();
|
||||
spawn_monitor(state, id, reference);
|
||||
spawn_monitor(state, record_key, reference);
|
||||
Ok((StatusCode::ACCEPTED, Json(deployment)))
|
||||
}
|
||||
|
||||
@@ -651,6 +792,7 @@ fn spawn_monitor(state: AppState, id: String, reference: BuildReference) {
|
||||
fn resume_monitors(state: &AppState) {
|
||||
let state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
prune_expired_records(&state).await;
|
||||
let pending: Vec<_> = state
|
||||
.deployments
|
||||
.read()
|
||||
@@ -699,18 +841,18 @@ async fn monitor_build(
|
||||
) -> Result<(), String> {
|
||||
let outcome = state
|
||||
.jenkins
|
||||
.wait_for_outcome(reference, |build_url| {
|
||||
.wait_for_outcome(reference, |build| {
|
||||
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.id = Some(build.number.to_string());
|
||||
record.public.status = match record.operation {
|
||||
Operation::Deploy => DeploymentStatus::Building,
|
||||
Operation::Uninstall => DeploymentStatus::Uninstalling,
|
||||
};
|
||||
record.public.jenkins_build_url = Some(build_url);
|
||||
record.public.jenkins_build_url = Some(build.public_url);
|
||||
record.public.updated_at = unix_now();
|
||||
record.public.message = Some(match record.operation {
|
||||
Operation::Deploy => "Jenkins 正在构建".to_string(),
|
||||
@@ -902,12 +1044,19 @@ fn load_deployments(config: &Config) -> Result<HashMap<String, DeploymentRecord>
|
||||
}
|
||||
let mut deployments = HashMap::new();
|
||||
for mut 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 record.instance_id.is_empty() {
|
||||
record.instance_id = derive_deployment_id(&branch);
|
||||
}
|
||||
if record.public.id.as_deref() == Some(record.instance_id.as_str()) {
|
||||
record.public.id = None;
|
||||
}
|
||||
recover_legacy_build_details(&mut record.public, config);
|
||||
validate_deployment_id(&record.instance_id)
|
||||
.map_err(|_| "状态文件包含无效预览实例 ID".to_string())?;
|
||||
if let Some(build_id) = record.public.id.as_deref() {
|
||||
validate_record_id(build_id).map_err(|_| "状态文件包含无效构建编号".to_string())?;
|
||||
}
|
||||
if let Some(commit) = record.public.commit_hash.as_deref() {
|
||||
validate_commit(commit).map_err(|_| "状态文件包含无效 commit".to_string())?;
|
||||
@@ -942,10 +1091,10 @@ fn load_deployments(config: &Config) -> Result<HashMap<String, DeploymentRecord>
|
||||
record.public.web_port = url_port;
|
||||
}
|
||||
if deployments
|
||||
.insert(record.public.id.clone(), record)
|
||||
.insert(record.instance_id.clone(), record)
|
||||
.is_some()
|
||||
{
|
||||
return Err("状态文件包含重复部署 ID".to_string());
|
||||
return Err("状态文件包含重复预览实例 ID".to_string());
|
||||
}
|
||||
}
|
||||
Ok(deployments)
|
||||
@@ -953,7 +1102,7 @@ fn load_deployments(config: &Config) -> Result<HashMap<String, DeploymentRecord>
|
||||
|
||||
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));
|
||||
deployments.sort_by(|left, right| left.public.created_at.cmp(&right.public.created_at));
|
||||
let bytes = serde_json::to_vec_pretty(&PersistedState {
|
||||
schema_version: 1,
|
||||
deployments,
|
||||
@@ -985,6 +1134,31 @@ async fn persist_deployments(state: &AppState) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn prune_expired_records(state: &AppState) {
|
||||
let now = unix_now();
|
||||
let mut deployments = state.deployments.write().await;
|
||||
let count_before = deployments.len();
|
||||
deployments.retain(|_, record| {
|
||||
let age = now.saturating_sub(record.public.updated_at);
|
||||
match record.public.status {
|
||||
DeploymentStatus::Failed | DeploymentStatus::Cancelled
|
||||
if !record.public.can_uninstall =>
|
||||
{
|
||||
age < FAILED_RECORD_TTL_SECS
|
||||
}
|
||||
DeploymentStatus::Stopped => age < STOPPED_RECORD_TTL_SECS,
|
||||
_ => true,
|
||||
}
|
||||
});
|
||||
let changed = deployments.len() != count_before;
|
||||
drop(deployments);
|
||||
if changed {
|
||||
if let Err(cause) = persist_deployments(state).await {
|
||||
error!(error = %cause, "failed to persist preview record cleanup");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn require_session(state: &AppState, headers: &HeaderMap) -> Result<(), ApiError> {
|
||||
if authenticated(state, headers).await {
|
||||
Ok(())
|
||||
@@ -1054,6 +1228,27 @@ fn validate_commit(raw: &str) -> Result<String, ApiError> {
|
||||
Ok(value.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
fn validate_search_query(raw: &str) -> Result<String, ApiError> {
|
||||
let value = raw.trim();
|
||||
if value.len() > 80 || !value.is_ascii() || value.bytes().any(|byte| byte.is_ascii_control()) {
|
||||
return Err(ApiError::bad_request("搜索关键词格式无效"));
|
||||
}
|
||||
Ok(value.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
fn validate_commit_search_query(raw: &str) -> Result<String, ApiError> {
|
||||
let value = raw.trim();
|
||||
if value.len() > 80 || !value.is_ascii() || value.bytes().any(|byte| byte.is_ascii_control()) {
|
||||
return Err(ApiError::bad_request("commit 搜索关键词格式无效"));
|
||||
}
|
||||
Ok(value.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
fn derive_deployment_id(branch: &str) -> String {
|
||||
let digest = format!("{:x}", Sha256::digest(branch.as_bytes()));
|
||||
format!("preview-{}", &digest[..16])
|
||||
}
|
||||
|
||||
fn validate_deployment_id(value: &str) -> Result<(), ApiError> {
|
||||
if value.len() != 24
|
||||
|| !value.starts_with("preview-")
|
||||
@@ -1066,9 +1261,45 @@ fn validate_deployment_id(value: &str) -> Result<(), ApiError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn derive_deployment_id(branch: &str) -> String {
|
||||
let digest = format!("{:x}", Sha256::digest(branch.as_bytes()));
|
||||
format!("preview-{}", &digest[..16])
|
||||
fn validate_record_id(value: &str) -> Result<(), ApiError> {
|
||||
value
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.filter(|number| *number > 0)
|
||||
.map(|_| ())
|
||||
.ok_or_else(|| ApiError::bad_request("构建编号无效"))
|
||||
}
|
||||
|
||||
fn recover_legacy_build_details(deployment: &mut Deployment, config: &Config) {
|
||||
let Some(build_url) = deployment.jenkins_build_url.as_deref() else {
|
||||
return;
|
||||
};
|
||||
let Some(number) = jenkins_build_number(build_url, &config.jenkins_base_url) else {
|
||||
deployment.jenkins_build_url = None;
|
||||
return;
|
||||
};
|
||||
if deployment.id.is_none() {
|
||||
deployment.id = Some(number.to_string());
|
||||
}
|
||||
deployment.jenkins_build_url = config
|
||||
.jenkins_public_base_url
|
||||
.join(&format!("{number}/"))
|
||||
.ok()
|
||||
.map(|url| url.to_string());
|
||||
}
|
||||
|
||||
fn jenkins_build_number(value: &str, job_url: &Url) -> Option<u64> {
|
||||
let parsed = Url::parse(value).ok()?;
|
||||
if !parsed.username().is_empty() || parsed.password().is_some() {
|
||||
return None;
|
||||
}
|
||||
parsed
|
||||
.path()
|
||||
.strip_prefix(job_url.path())?
|
||||
.trim_end_matches('/')
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.filter(|number| *number > 0)
|
||||
}
|
||||
|
||||
fn map_artifact_status(value: &str) -> Option<DeploymentStatus> {
|
||||
|
||||
@@ -157,10 +157,16 @@ fn test_config(jenkins_base_url: Url) -> Config {
|
||||
.unwrap();
|
||||
Config {
|
||||
bind_address: "127.0.0.1:0".to_string(),
|
||||
jenkins_root_url: jenkins_base_url,
|
||||
jenkins_root_url: jenkins_base_url.clone(),
|
||||
jenkins_base_url: job_url,
|
||||
jenkins_public_base_url: Url::parse(
|
||||
"http://192.168.35.82:8080/jenkins/job/shared/job/Genarrative-Preview-Deployer/",
|
||||
)
|
||||
.unwrap(),
|
||||
jenkins_username: "preview-service".to_string(),
|
||||
jenkins_api_token: "server-only-jenkins-token".to_string(),
|
||||
git_remote_url: "test://preview-repository".to_string(),
|
||||
git_ssh_command: None,
|
||||
access_token: "correct horse battery staple".to_string(),
|
||||
allowed_hosts: vec![HOST.to_string()],
|
||||
allowed_origins: vec![ORIGIN.to_string()],
|
||||
@@ -365,6 +371,11 @@ async fn deploy_and_uninstall_use_fixed_job_and_apply_owned_artifacts() {
|
||||
.public
|
||||
.clone();
|
||||
assert_eq!(deployment.status, super::DeploymentStatus::Running);
|
||||
assert_eq!(deployment.id.as_deref(), Some("1"));
|
||||
assert_eq!(
|
||||
deployment.jenkins_build_url.as_deref(),
|
||||
Some("http://192.168.35.82:8080/jenkins/job/shared/job/Genarrative-Preview-Deployer/1/")
|
||||
);
|
||||
assert_eq!(deployment.health, super::HealthStatus::Healthy);
|
||||
assert_eq!(deployment.web_port, Some(8400));
|
||||
assert_eq!(
|
||||
@@ -379,7 +390,7 @@ async fn deploy_and_uninstall_use_fixed_job_and_apply_owned_artifacts() {
|
||||
|
||||
let uninstall_request = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!("/api/preview-deployer/deployments/{id}/uninstall"))
|
||||
.uri("/api/preview-deployer/deployments/1/uninstall")
|
||||
.header(header::HOST, HOST)
|
||||
.header(header::ORIGIN, ORIGIN)
|
||||
.header(header::COOKIE, &cookie)
|
||||
@@ -464,6 +475,78 @@ async fn deploy_and_uninstall_use_fixed_job_and_apply_owned_artifacts() {
|
||||
std::fs::remove_file(state_file).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ref_search_requires_session_and_returns_server_side_matches() {
|
||||
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/refs/branches?q=preview",
|
||||
Body::empty(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(anonymous.status(), StatusCode::UNAUTHORIZED);
|
||||
let cookie = login_cookie(&app).await;
|
||||
let request = axum::http::Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/preview-deployer/refs/branches?q=preview")
|
||||
.header(header::HOST, HOST)
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let response = app.clone().oneshot(request).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let value: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(value["items"][0]["name"], "feature/preview-ui");
|
||||
|
||||
let request = axum::http::Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/preview-deployer/refs/commits?branch=feature%2Fpreview-ui&q=0123456")
|
||||
.header(header::HOST, HOST)
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let response = app.oneshot(request).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let value: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(
|
||||
value["items"][0]["commitHash"],
|
||||
"0123456789abcdef0123456789abcdef01234567"
|
||||
);
|
||||
assert_eq!(value["items"][0]["shortHash"], "0123456");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deployment_rejects_missing_or_unrelated_refs_before_jenkins() {
|
||||
let (jenkins_url, mock) = start_mock_jenkins().await;
|
||||
let app = build_router(AppState::new(test_config(jenkins_url)).unwrap());
|
||||
let cookie = login_cookie(&app).await;
|
||||
for payload in [
|
||||
r#"{"branch":"feature/missing"}"#,
|
||||
r#"{"branch":"feature/preview-ui","commitHash":"deadbee"}"#,
|
||||
] {
|
||||
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(payload))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
app.clone().oneshot(request).await.unwrap().status(),
|
||||
StatusCode::UNPROCESSABLE_ENTITY
|
||||
);
|
||||
}
|
||||
assert!(mock.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transient_invalid_build_status_is_retried_before_reading_artifact() {
|
||||
let (jenkins_url, mock) = start_mock_jenkins().await;
|
||||
@@ -514,7 +597,7 @@ async fn duplicate_active_branch_is_rejected_without_second_jenkins_trigger() {
|
||||
id.clone(),
|
||||
super::DeploymentRecord {
|
||||
public: super::Deployment {
|
||||
id,
|
||||
id: None,
|
||||
branch: "feature/busy".to_string(),
|
||||
commit_hash: None,
|
||||
resolved_commit: None,
|
||||
@@ -528,6 +611,7 @@ async fn duplicate_active_branch_is_rejected_without_second_jenkins_trigger() {
|
||||
message: None,
|
||||
can_uninstall: false,
|
||||
},
|
||||
instance_id: id,
|
||||
operation: super::Operation::Deploy,
|
||||
queue_url: None,
|
||||
},
|
||||
@@ -614,6 +698,7 @@ fn legacy_running_state_recovers_web_port_from_validated_url() {
|
||||
"status": "running",
|
||||
"health": "healthy",
|
||||
"webUrl": "http://192.168.35.82:8407",
|
||||
"jenkinsBuildUrl": "http://127.0.0.1:18080/jenkins/job/shared/job/Genarrative-Preview-Deployer/42/",
|
||||
"createdAt": 1,
|
||||
"updatedAt": 2,
|
||||
"canUninstall": true
|
||||
@@ -636,5 +721,103 @@ fn legacy_running_state_recovers_web_port_from_validated_url() {
|
||||
.web_port,
|
||||
Some(8407)
|
||||
);
|
||||
let deployment = state
|
||||
.deployments
|
||||
.blocking_read()
|
||||
.get(&id)
|
||||
.unwrap()
|
||||
.public
|
||||
.clone();
|
||||
assert_eq!(deployment.id.as_deref(), Some("42"));
|
||||
assert_eq!(
|
||||
deployment.jenkins_build_url.as_deref(),
|
||||
Some("http://192.168.35.82:8080/jenkins/job/shared/job/Genarrative-Preview-Deployer/42/")
|
||||
);
|
||||
std::fs::remove_file(&state.config.state_file).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expired_terminal_records_are_pruned_but_uninstallable_failures_are_retained() {
|
||||
let (jenkins_url, _) = 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 old = super::unix_now().saturating_sub(super::FAILED_RECORD_TTL_SECS + 1);
|
||||
let failed_id = super::derive_deployment_id("feature/old-failure");
|
||||
let uninstallable_id = super::derive_deployment_id("feature/failed-uninstall");
|
||||
state.deployments.write().await.extend([
|
||||
(
|
||||
failed_id.clone(),
|
||||
super::DeploymentRecord {
|
||||
public: super::Deployment {
|
||||
id: Some("91".to_string()),
|
||||
branch: "feature/old-failure".to_string(),
|
||||
commit_hash: None,
|
||||
resolved_commit: None,
|
||||
status: super::DeploymentStatus::Failed,
|
||||
health: super::HealthStatus::Unknown,
|
||||
web_port: None,
|
||||
web_url: None,
|
||||
jenkins_build_url: None,
|
||||
created_at: old,
|
||||
updated_at: old,
|
||||
message: None,
|
||||
can_uninstall: false,
|
||||
},
|
||||
instance_id: failed_id.clone(),
|
||||
operation: super::Operation::Deploy,
|
||||
queue_url: None,
|
||||
},
|
||||
),
|
||||
(
|
||||
uninstallable_id.clone(),
|
||||
super::DeploymentRecord {
|
||||
public: super::Deployment {
|
||||
id: Some("92".to_string()),
|
||||
branch: "feature/failed-uninstall".to_string(),
|
||||
commit_hash: None,
|
||||
resolved_commit: None,
|
||||
status: super::DeploymentStatus::Failed,
|
||||
health: super::HealthStatus::Unknown,
|
||||
web_port: Some(8401),
|
||||
web_url: Some("http://192.168.35.82:8401".to_string()),
|
||||
jenkins_build_url: None,
|
||||
created_at: old,
|
||||
updated_at: old,
|
||||
message: None,
|
||||
can_uninstall: true,
|
||||
},
|
||||
instance_id: uninstallable_id.clone(),
|
||||
operation: super::Operation::Uninstall,
|
||||
queue_url: None,
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/preview-deployer/deployments")
|
||||
.header(header::HOST, HOST)
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let listed: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(listed["deployments"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(listed["deployments"][0]["id"], "92");
|
||||
assert!(!state.deployments.read().await.contains_key(&failed_id));
|
||||
assert!(
|
||||
state
|
||||
.deployments
|
||||
.read()
|
||||
.await
|
||||
.contains_key(&uninstallable_id)
|
||||
);
|
||||
std::fs::remove_file(&state.config.state_file).unwrap();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user