修复Jenkins成功收尾状态竞态
Project CI / Repository checks (push) Successful in 53s
Project CI / Frontend tests (push) Successful in 3m36s
Project CI / Native shell tests (push) Successful in 15m35s
Project CI / Backend tests (push) Successful in 4m19s

为队列、构建状态和部署产物查询增加有限重试

避免 Jenkins 瞬态无效响应永久覆盖成功部署状态

增加构建收尾竞态回归测试和技术说明
This commit is contained in:
2026-08-15 18:38:59 +08:00
parent ea81d8ebb5
commit cf5eac1c63
3 changed files with 75 additions and 7 deletions
@@ -92,6 +92,8 @@ SpacetimeDB 2.7 CLI 发布到受控 Compose 网络地址时固定使用 `--yes=r
页面状态统一为 `queued / building / deploying / running / uninstalling / stopped / failed / cancelled`,健康状态统一为 `pending / healthy / unhealthy / unknown`
Jenkins 在构建完成、归档 artifact 和更新 REST 状态之间可能短暂返回不可解析的状态正文。控制服务对队列、构建状态和 artifact 查询执行有限重试;单次瞬态响应不得把已经成功并健康的部署永久写成 `failed`
## 安全边界
- 服务端缺少控制面访问口令或 Jenkins service account 凭据时必须拒绝启动,不允许退化成匿名写接口。
@@ -1,7 +1,7 @@
use std::{future::Future, time::Duration};
use reqwest::{Client, StatusCode, header};
use serde::Deserialize;
use serde::{Deserialize, de::DeserializeOwned};
use url::Url;
use crate::{Config, DeploymentStatus, HealthStatus};
@@ -209,7 +209,7 @@ impl JenkinsClient {
.queue_url
.join("api/json")
.map_err(|error| error.to_string())?;
let item: QueueItem = self.get_json(item_url).await?;
let item: QueueItem = self.get_json_with_retry(item_url).await?;
if item.cancelled.unwrap_or(false) {
return Ok(JenkinsOutcome {
success: false,
@@ -229,7 +229,7 @@ impl JenkinsClient {
let state_url = build_url
.join("api/json")
.map_err(|error| error.to_string())?;
let state: BuildState = self.get_json(state_url).await?;
let state: BuildState = self.get_json_with_retry(state_url).await?;
if state.building {
tokio::time::sleep(self.poll_interval).await;
continue;
@@ -247,7 +247,7 @@ impl JenkinsClient {
let artifact_url = build_url
.join("artifact/preview-result.json")
.map_err(|error| error.to_string())?;
let result = self.get_json(artifact_url).await?;
let result = self.get_json_with_retry(artifact_url).await?;
Ok(JenkinsOutcome {
success: true,
cancelled: false,
@@ -273,6 +273,21 @@ impl JenkinsClient {
.map_err(|_| "Jenkins 状态响应格式无效".to_string())
}
async fn get_json_with_retry<T: DeserializeOwned>(&self, url: Url) -> Result<T, String> {
const MAX_ATTEMPTS: usize = 10;
let mut last_error = None;
for attempt in 1..=MAX_ATTEMPTS {
match self.get_json(url.clone()).await {
Ok(value) => return Ok(value),
Err(error) => last_error = Some(error),
}
if attempt < MAX_ATTEMPTS {
tokio::time::sleep(self.poll_interval).await;
}
}
Err(last_error.unwrap_or_else(|| "Jenkins 状态请求失败".to_string()))
}
fn resolve_trusted_url(&self, value: &str) -> Result<Url, String> {
let returned = Url::parse(value)
.or_else(|_| self.job_url.join(value))
@@ -1,5 +1,8 @@
use std::{
sync::{Arc, Mutex},
sync::{
Arc, Mutex,
atomic::{AtomicUsize, Ordering},
},
time::Duration,
};
@@ -25,6 +28,7 @@ const ORIGIN: &str = "http://preview.internal:8080";
#[derive(Clone, Default)]
struct MockJenkinsState {
requests: Arc<Mutex<Vec<RecordedRequest>>>,
invalid_build_responses: Arc<AtomicUsize>,
}
#[derive(Debug)]
@@ -99,8 +103,17 @@ async fn mock_queue(axum::extract::Path(id): axum::extract::Path<u64>) -> Json<V
)
}
async fn mock_build() -> Json<Value> {
Json(json!({"building": false, "result": "SUCCESS"}))
async fn mock_build(State(state): State<MockJenkinsState>) -> impl IntoResponse {
if state
.invalid_build_responses
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| {
remaining.checked_sub(1)
})
.is_ok()
{
return (StatusCode::OK, "Jenkins is finalizing the build").into_response();
}
Json(json!({"building": false, "result": "SUCCESS"})).into_response()
}
async fn mock_artifact(axum::extract::Path(build): axum::extract::Path<u64>) -> Json<Value> {
@@ -424,6 +437,44 @@ async fn deploy_and_uninstall_use_fixed_job_and_apply_owned_artifacts() {
std::fs::remove_file(state_file).unwrap();
}
#[tokio::test]
async fn transient_invalid_build_status_is_retried_before_reading_artifact() {
let (jenkins_url, mock) = start_mock_jenkins().await;
mock.invalid_build_responses.store(1, Ordering::SeqCst);
let state = AppState::new(test_config(jenkins_url)).unwrap();
let app = build_router(state.clone());
let cookie = login_cookie(&app).await;
let id = super::derive_deployment_id("feature/preview-ui");
let deploy_request = axum::http::Request::builder()
.method("POST")
.uri("/api/preview-deployer/deployments")
.header(header::HOST, HOST)
.header(header::ORIGIN, ORIGIN)
.header(header::COOKIE, &cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"branch":"feature/preview-ui"}"#))
.unwrap();
assert_eq!(
app.oneshot(deploy_request).await.unwrap().status(),
StatusCode::ACCEPTED
);
wait_for_status(&state, &id, super::DeploymentStatus::Running)
.await
.expect("transient invalid Jenkins response is retried");
assert_eq!(
state
.deployments
.read()
.await
.get(&id)
.unwrap()
.public
.health,
super::HealthStatus::Healthy
);
std::fs::remove_file(&state.config.state_file).unwrap();
}
#[tokio::test]
async fn duplicate_active_branch_is_rejected_without_second_jenkins_trigger() {
let (jenkins_url, mock) = start_mock_jenkins().await;