Files
Genarrative/server-rs/crates/preview-deployer-server/src/jenkins.rs
T
kdletters 38ae9d7d07
Project CI / Repository checks (push) Successful in 1m6s
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
完善预览发布记录管理
发布记录改用 Jenkins 构建编号并展示

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

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

同步测试、部署配置和运维文档
2026-08-17 12:42:29 +08:00

379 lines
12 KiB
Rust

use std::{future::Future, time::Duration};
use reqwest::{Client, StatusCode, header};
use serde::{Deserialize, de::DeserializeOwned};
use url::Url;
use crate::{Config, DeploymentStatus, HealthStatus};
#[derive(Clone)]
pub struct JenkinsClient {
http: Client,
root_url: Url,
job_url: Url,
public_job_url: Url,
username: String,
api_token: String,
poll_interval: Duration,
}
pub enum BuildAction<'a> {
Deploy {
deployment_id: &'a str,
branch: &'a str,
commit_hash: Option<&'a str>,
},
Uninstall {
deployment_id: &'a str,
branch: &'a str,
},
}
pub struct BuildReference {
queue_url: Url,
}
pub struct BuildStarted {
pub number: u64,
pub public_url: String,
}
impl BuildReference {
pub fn as_str(&self) -> &str {
self.queue_url.as_str()
}
}
pub struct JenkinsOutcome {
pub success: bool,
pub cancelled: bool,
pub result: Option<PreviewResult>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PreviewResult {
pub schema_version: Option<u8>,
pub action: Option<String>,
#[serde(alias = "id")]
pub deployment_id: Option<String>,
pub project_name: Option<String>,
pub branch: Option<String>,
pub resolved_commit: Option<String>,
pub status: Option<DeploymentStatus>,
pub health: Option<HealthStatus>,
pub phase: Option<String>,
pub health_status: Option<String>,
pub web_port: Option<u16>,
pub web_url: Option<String>,
pub message: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Crumb {
crumb_request_field: String,
crumb: String,
}
#[derive(Deserialize)]
struct QueueItem {
cancelled: Option<bool>,
executable: Option<Executable>,
}
#[derive(Deserialize)]
struct Executable {
url: String,
}
#[derive(Deserialize)]
struct BuildState {
building: bool,
result: Option<String>,
}
impl JenkinsClient {
pub fn new(config: &Config) -> Result<Self, String> {
let http = Client::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(Duration::from_secs(20))
.build()
.map_err(|error| format!("无法创建 Jenkins HTTP client: {error}"))?;
Ok(Self {
http,
root_url: config.jenkins_root_url.clone(),
job_url: config.jenkins_base_url.clone(),
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,
})
}
pub async fn trigger(&self, action: BuildAction<'_>) -> Result<BuildReference, String> {
let mut parameters = Vec::new();
match action {
BuildAction::Deploy {
deployment_id,
branch,
commit_hash,
} => {
parameters.push(("ACTION", "DEPLOY"));
parameters.push(("DEPLOYMENT_ID", deployment_id));
parameters.push(("SOURCE_BRANCH", branch));
parameters.push(("COMMIT_HASH", commit_hash.unwrap_or("")));
}
BuildAction::Uninstall {
deployment_id,
branch,
} => {
parameters.push(("ACTION", "UNINSTALL"));
parameters.push(("DEPLOYMENT_ID", deployment_id));
parameters.push(("SOURCE_BRANCH", branch));
parameters.push(("COMMIT_HASH", ""));
}
}
let trigger_url = self
.job_url
.join("buildWithParameters")
.map_err(|error| error.to_string())?;
let mut request = self
.http
.post(trigger_url)
.basic_auth(&self.username, Some(&self.api_token))
.form(&parameters);
if let Some((field, value)) = self.crumb().await? {
let field = header::HeaderName::from_bytes(field.as_bytes())
.map_err(|_| "Jenkins crumb header 名称无效".to_string())?;
let value = header::HeaderValue::from_str(&value)
.map_err(|_| "Jenkins crumb header 值无效".to_string())?;
request = request.header(field, value);
}
let response = request
.send()
.await
.map_err(|error| format!("Jenkins trigger 请求失败: {error}"))?;
if !response.status().is_success() {
return Err(format!("Jenkins trigger 返回 HTTP {}", response.status()));
}
let location = response
.headers()
.get(header::LOCATION)
.and_then(|value| value.to_str().ok())
.ok_or_else(|| "Jenkins trigger 响应缺少 queue Location".to_string())?;
let queue_url = self.resolve_trusted_url(location)?;
Ok(BuildReference { queue_url })
}
pub fn restore_reference(&self, value: &str) -> Result<BuildReference, String> {
Ok(BuildReference {
queue_url: self.resolve_trusted_url(value)?,
})
}
async fn crumb(&self) -> Result<Option<(String, String)>, String> {
let crumb_url = self
.root_url
.join("crumbIssuer/api/json")
.map_err(|error| error.to_string())?;
let response = self
.http
.get(crumb_url)
.basic_auth(&self.username, Some(&self.api_token))
.send()
.await
.map_err(|error| format!("Jenkins crumb 请求失败: {error}"))?;
if matches!(
response.status(),
StatusCode::NOT_FOUND | StatusCode::FORBIDDEN
) {
return Ok(None);
}
if !response.status().is_success() {
return Err(format!("Jenkins crumb 返回 HTTP {}", response.status()));
}
let crumb: Crumb = response
.json()
.await
.map_err(|_| "Jenkins crumb 响应格式无效".to_string())?;
Ok(Some((crumb.crumb_request_field, crumb.crumb)))
}
pub async fn wait_for_outcome<F, Fut>(
&self,
reference: BuildReference,
mut on_build: F,
) -> Result<JenkinsOutcome, String>
where
F: FnMut(BuildStarted) -> Fut,
Fut: Future<Output = ()>,
{
let build_url = loop {
let mut item_url = reference
.queue_url
.join("api/json")
.map_err(|error| error.to_string())?;
item_url
.query_pairs_mut()
.append_pair("tree", "cancelled,executable[url]");
let item: QueueItem = self.get_json_with_retry(item_url).await?;
if item.cancelled.unwrap_or(false) {
return Ok(JenkinsOutcome {
success: false,
cancelled: true,
result: None,
});
}
if let Some(executable) = item.executable {
let url = self.resolve_trusted_url(&executable.url)?;
break url;
}
tokio::time::sleep(self.poll_interval).await;
};
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")
.map_err(|error| error.to_string())?;
state_url
.query_pairs_mut()
.append_pair("tree", "building,result");
let state: BuildState = self.get_json_with_retry(state_url).await?;
if state.building {
tokio::time::sleep(self.poll_interval).await;
continue;
}
break state.result.as_deref() == Some("SUCCESS");
};
if !successful {
return Ok(JenkinsOutcome {
success: false,
cancelled: false,
result: None,
});
}
let artifact_url = build_url
.join("artifact/preview-result.json")
.map_err(|error| error.to_string())?;
let result = self.get_json_with_retry(artifact_url).await?;
Ok(JenkinsOutcome {
success: true,
cancelled: false,
result: Some(result),
})
}
async fn get_json<T: for<'de> Deserialize<'de>>(&self, url: Url) -> Result<T, String> {
self.ensure_same_origin(&url)?;
let response = self
.http
.get(url)
.basic_auth(&self.username, Some(&self.api_token))
.send()
.await
.map_err(|error| format!("Jenkins 状态请求失败: {error}"))?;
if !response.status().is_success() {
return Err(format!("Jenkins 状态返回 HTTP {}", response.status()));
}
let bytes = response
.bytes()
.await
.map_err(|error| format!("Jenkins 状态响应正文读取失败: {error}"))?;
serde_json::from_slice(&bytes).map_err(|error| format!("Jenkins 状态响应格式无效: {error}"))
}
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))
.map_err(|_| "Jenkins 返回了无效 URL".to_string())?;
if !returned.username().is_empty()
|| returned.password().is_some()
|| !returned.path().starts_with(self.root_url.path())
{
return Err("Jenkins 返回了非受信源 URL".to_string());
}
let relative_path = returned
.path()
.strip_prefix(self.root_url.path())
.ok_or_else(|| "Jenkins 返回了非受信路径".to_string())?;
let mut trusted = self
.root_url
.join(relative_path)
.map_err(|_| "Jenkins 返回了无效 URL".to_string())?;
trusted.set_query(returned.query());
trusted.set_fragment(None);
self.ensure_same_origin(&trusted)?;
Ok(trusted)
}
fn ensure_same_origin(&self, url: &Url) -> Result<(), String> {
if url.username().is_empty()
&& url.password().is_none()
&& url.scheme() == self.root_url.scheme()
&& url.host_str() == self.root_url.host_str()
&& url.port_or_known_default() == self.root_url.port_or_known_default()
&& url.path().starts_with(self.root_url.path())
{
Ok(())
} else {
Err("Jenkins 返回了非受信源 URL".to_string())
}
}
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())
}