完善预览发布记录管理
Project CI / Frontend tests (push) Successful in 2m55s
Project CI / Backend tests (push) Successful in 3m53s
Project CI / Native shell tests (push) Successful in 15m2s
Project CI / Repository checks (push) Successful in 1m6s

发布记录改用 Jenkins 构建编号并展示

失败与停止记录增加自动清理期限

构建详情改为局域网 Jenkins 地址

同步测试、部署配置和运维文档
This commit is contained in:
2026-08-17 12:42:29 +08:00
parent abb3912530
commit 38ae9d7d07
13 changed files with 300 additions and 36 deletions
@@ -27,7 +27,7 @@ beforeEach(() => {
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',
@@ -35,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',
@@ -83,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',
@@ -98,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');
@@ -107,7 +108,7 @@ 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');
});
});
@@ -289,6 +289,9 @@ export function PreviewDeployerApp() {
return;
}
const deployment = pendingUninstall;
if (!deployment.id) {
return;
}
setPendingUninstall(null);
setUninstallingId(deployment.id);
setError('');
@@ -676,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)}
@@ -774,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>
@@ -811,7 +820,7 @@ function DeploymentCard({
<ExternalLink size={13} />
</a>
) : null}
{canUninstall ? (
{canUninstall && deployment.id ? (
<button
className="icon-danger-button"
type="button"
+5
View File
@@ -449,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;
+1 -1
View File
@@ -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;
+2
View File
@@ -1,6 +1,8 @@
# 仅部署在本机内网 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。
@@ -14161,3 +14161,9 @@
- 实例与端口:分支规范化后形成稳定 `deploymentId`,同一分支换 commit 复用实例和 Web 端口;不同分支使用独立 Compose project。Web 端口在全局文件锁内从 `8400..8499` 分配,状态表与宿主监听同时空闲才可占用,卸载后释放。SpacetimeDB 与 OTLP 不映射宿主端口,Jenkins 通过受控 Compose 网络发布模块;页面只展示 Web 内网地址。
- 来源与卸载:部署只接受 `SOURCE_BRANCH` 和可选 `COMMIT_HASH`Jenkins 必须证明 commit 属于目标分支。卸载只接受受控状态中存在的 `deploymentId`,客户端不能传 Jenkins URL、Job、Compose project、容器名或端口。状态通过固定 `preview-result.json` artifact 返回,不解析或向浏览器暴露完整 console。
- 关联文档:`docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 2026-08-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`
@@ -94,7 +94,7 @@ SpacetimeDB 2.7 CLI 发布到受控 Compose 网络地址时固定使用 `--yes=r
页面状态统一为 `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 继续执行相同的最终归属校验,以覆盖预检到排队之间的分支变化。
@@ -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 TokenSPA 通过同源代理触发固定 `shared/Genarrative-Preview-Deployer` Job。分支和 commit 输入框通过受认证的控制服务搜索固定内网 Git 仓库并展示下拉结果;提交构建前控制服务重新确认分支存在、可选 commit 存在且属于目标分支,失败时不触发 JenkinsJenkins checkout 仍保留最终复核。每个分支使用稳定 `deploymentId` 和独立 Compose projectWeb 端口从 `8400..8499` 在文件锁内分配,同一分支换 commit 优先复用端口,卸载后释放。Jenkins 用 `preview-result.json` 向页面提供 resolved commit、发布结果和内网 Web URL,页面刷新时由控制服务实时复核 Web 健康。安装资产为 `deploy/systemd/genarrative-preview-deployer.service``deploy/env/preview-deployer.env.example``deploy/nginx/genarrative-preview-deployer-lan.conf`;完整合同见 `docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md`
多人内网预览入口固定为 `http://192.168.35.82/build/`,不配置公网域名。该独立 Jenkins 容器预览部署控制面不让浏览器直接操作 Docker 或持有 Jenkins TokenSPA 通过同源代理触发固定 `shared/Genarrative-Preview-Deployer` Job。分支和 commit 输入框通过受认证的控制服务搜索固定内网 Git 仓库并展示下拉结果;提交构建前控制服务重新确认分支存在、可选 commit 存在且属于目标分支,失败时不触发 JenkinsJenkins checkout 仍保留最终复核。每个分支使用稳定的内部 `deploymentId` 和独立 Compose projectWeb 端口从 `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 排空另行验证。
+19
View File
@@ -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>',
@@ -9,6 +9,7 @@ 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,
@@ -30,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(),
@@ -79,6 +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());
@@ -124,6 +148,7 @@ 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(
@@ -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())
}
@@ -34,6 +34,8 @@ 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 {
@@ -100,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>,
@@ -124,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>,
@@ -448,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
@@ -506,13 +512,14 @@ async fn get_deployment(
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))
@@ -588,6 +595,7 @@ 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
@@ -617,7 +625,7 @@ async fn create_deployment(
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,
@@ -648,6 +656,7 @@ async fn create_deployment(
id.clone(),
DeploymentRecord {
public: deployment.clone(),
instance_id: id.clone(),
operation: Operation::Deploy,
queue_url: None,
},
@@ -684,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
@@ -707,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
@@ -716,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
@@ -724,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;
@@ -744,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)))
}
@@ -776,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()
@@ -824,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(),
@@ -1027,12 +1044,18 @@ 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;
}
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())?;
@@ -1067,10 +1090,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)
@@ -1078,7 +1101,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,
@@ -1110,6 +1133,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(())
@@ -1212,6 +1260,15 @@ fn validate_deployment_id(value: &str) -> Result<(), ApiError> {
Ok(())
}
fn validate_record_id(value: &str) -> Result<(), ApiError> {
value
.parse::<u64>()
.ok()
.filter(|number| *number > 0)
.map(|_| ())
.ok_or_else(|| ApiError::bad_request("构建编号无效"))
}
fn map_artifact_status(value: &str) -> Option<DeploymentStatus> {
match value.to_ascii_uppercase().as_str() {
"QUEUED" => Some(DeploymentStatus::Queued),
@@ -159,6 +159,10 @@ fn test_config(jenkins_base_url: Url) -> Config {
bind_address: "127.0.0.1:0".to_string(),
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(),
@@ -367,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!(
@@ -381,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)
@@ -588,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,
@@ -602,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,
},
@@ -712,3 +722,89 @@ fn legacy_running_state_recovers_web_port_from_validated_url() {
);
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();
}