379 lines
12 KiB
Rust
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(¶meters);
|
|
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())
|
|
}
|