1360 lines
45 KiB
Rust
1360 lines
45 KiB
Rust
mod config;
|
|
mod git_refs;
|
|
mod jenkins;
|
|
|
|
use std::{
|
|
collections::HashMap,
|
|
sync::Arc,
|
|
time::{Duration, SystemTime, UNIX_EPOCH},
|
|
};
|
|
|
|
use axum::extract::DefaultBodyLimit;
|
|
use axum::{
|
|
Json, Router,
|
|
extract::{Path, Query, Request, State},
|
|
http::{HeaderMap, HeaderValue, StatusCode, header},
|
|
middleware::{self, Next},
|
|
response::{IntoResponse, Response},
|
|
routing::{get, post},
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use sha2::{Digest, Sha256};
|
|
use tokio::sync::RwLock;
|
|
use tower_http::{
|
|
services::{ServeDir, ServeFile},
|
|
trace::TraceLayer,
|
|
};
|
|
use tracing::{error, warn};
|
|
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>>>,
|
|
}
|
|
|
|
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())),
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub enum DeploymentStatus {
|
|
Queued,
|
|
Building,
|
|
Deploying,
|
|
Running,
|
|
Uninstalling,
|
|
Stopped,
|
|
Failed,
|
|
Cancelled,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub enum HealthStatus {
|
|
Pending,
|
|
Healthy,
|
|
Unhealthy,
|
|
Unknown,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct Deployment {
|
|
#[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>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub resolved_commit: Option<String>,
|
|
pub status: DeploymentStatus,
|
|
pub health: HealthStatus,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub web_port: Option<u16>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub web_url: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub jenkins_build_url: Option<String>,
|
|
pub created_at: u64,
|
|
pub updated_at: u64,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub message: Option<String>,
|
|
pub can_uninstall: bool,
|
|
}
|
|
|
|
#[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>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
enum Operation {
|
|
Deploy,
|
|
Uninstall,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
struct PersistedState {
|
|
schema_version: u8,
|
|
deployments: Vec<DeploymentRecord>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
struct SessionRequest {
|
|
access_token: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct SessionResponse {
|
|
authenticated: bool,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
struct DeployRequest {
|
|
branch: String,
|
|
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>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct ErrorBody {
|
|
error: &'static str,
|
|
message: String,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct ApiError {
|
|
status: StatusCode,
|
|
code: &'static str,
|
|
message: String,
|
|
}
|
|
|
|
impl ApiError {
|
|
fn bad_request(message: impl Into<String>) -> Self {
|
|
Self {
|
|
status: StatusCode::BAD_REQUEST,
|
|
code: "invalid_request",
|
|
message: message.into(),
|
|
}
|
|
}
|
|
|
|
fn unauthorized() -> Self {
|
|
Self {
|
|
status: StatusCode::UNAUTHORIZED,
|
|
code: "authentication_required",
|
|
message: "请先登录预览部署控制台".to_string(),
|
|
}
|
|
}
|
|
|
|
fn not_found() -> Self {
|
|
Self {
|
|
status: StatusCode::NOT_FOUND,
|
|
code: "deployment_not_found",
|
|
message: "预览实例不存在".to_string(),
|
|
}
|
|
}
|
|
|
|
fn conflict(message: impl Into<String>) -> Self {
|
|
Self {
|
|
status: StatusCode::CONFLICT,
|
|
code: "deployment_conflict",
|
|
message: message.into(),
|
|
}
|
|
}
|
|
|
|
fn upstream(message: impl Into<String>) -> Self {
|
|
Self {
|
|
status: StatusCode::BAD_GATEWAY,
|
|
code: "jenkins_unavailable",
|
|
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 {
|
|
fn into_response(self) -> Response {
|
|
(
|
|
self.status,
|
|
Json(ErrorBody {
|
|
error: self.code,
|
|
message: self.message,
|
|
}),
|
|
)
|
|
.into_response()
|
|
}
|
|
}
|
|
|
|
pub fn build_router(state: AppState) -> Router {
|
|
resume_monitors(&state);
|
|
let api = Router::new()
|
|
.route("/healthz", get(healthz))
|
|
.route(
|
|
"/api/preview-deployer/session",
|
|
post(login).get(session_status).delete(logout),
|
|
)
|
|
.route(
|
|
"/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),
|
|
)
|
|
.route(
|
|
"/api/preview-deployer/deployments/{id}/uninstall",
|
|
post(uninstall_deployment),
|
|
);
|
|
|
|
let mut router = Router::new().merge(api);
|
|
if let Some(static_dir) = state.config.static_dir.clone() {
|
|
let index = static_dir.join("index.html");
|
|
router = router
|
|
.route_service("/build", ServeFile::new(index.clone()))
|
|
.route_service("/build/", ServeFile::new(index.clone()))
|
|
.nest_service("/build/assets", ServeDir::new(static_dir.join("assets")))
|
|
.fallback_service(
|
|
ServeDir::new(&static_dir)
|
|
.append_index_html_on_directories(true)
|
|
.fallback(ServeFile::new(index)),
|
|
);
|
|
}
|
|
|
|
router
|
|
.layer(DefaultBodyLimit::max(8 * 1024))
|
|
.layer(TraceLayer::new_for_http())
|
|
.layer(middleware::from_fn_with_state(
|
|
state.clone(),
|
|
enforce_request_boundary,
|
|
))
|
|
.with_state(state)
|
|
}
|
|
|
|
async fn healthz() -> StatusCode {
|
|
StatusCode::NO_CONTENT
|
|
}
|
|
|
|
async fn enforce_request_boundary(
|
|
State(state): State<AppState>,
|
|
request: Request,
|
|
next: Next,
|
|
) -> Result<Response, ApiError> {
|
|
let headers = request.headers();
|
|
let host = headers
|
|
.get(header::HOST)
|
|
.and_then(|value| value.to_str().ok())
|
|
.unwrap_or("");
|
|
if !state
|
|
.config
|
|
.allowed_hosts
|
|
.iter()
|
|
.any(|allowed| constant_time_eq(host.as_bytes(), allowed.as_bytes()))
|
|
{
|
|
return Err(ApiError {
|
|
status: StatusCode::FORBIDDEN,
|
|
code: "host_forbidden",
|
|
message: "请求 Host 不受信任".to_string(),
|
|
});
|
|
}
|
|
|
|
if request.uri().path().starts_with("/api/") && request.method() != axum::http::Method::GET {
|
|
let origin = headers
|
|
.get(header::ORIGIN)
|
|
.and_then(|value| value.to_str().ok())
|
|
.unwrap_or("");
|
|
if !state
|
|
.config
|
|
.allowed_origins
|
|
.iter()
|
|
.any(|allowed| constant_time_eq(origin.as_bytes(), allowed.as_bytes()))
|
|
{
|
|
return Err(ApiError {
|
|
status: StatusCode::FORBIDDEN,
|
|
code: "origin_forbidden",
|
|
message: "请求 Origin 不受信任".to_string(),
|
|
});
|
|
}
|
|
}
|
|
|
|
Ok(next.run(request).await)
|
|
}
|
|
|
|
async fn login(
|
|
State(state): State<AppState>,
|
|
Json(payload): Json<SessionRequest>,
|
|
) -> Result<Response, ApiError> {
|
|
if !constant_time_eq(
|
|
payload.access_token.as_bytes(),
|
|
state.config.access_token.as_bytes(),
|
|
) {
|
|
return Err(ApiError::unauthorized());
|
|
}
|
|
|
|
let session_id = Uuid::new_v4().simple().to_string();
|
|
let expires_at = unix_now().saturating_add(SESSION_TTL.as_secs());
|
|
state
|
|
.sessions
|
|
.write()
|
|
.await
|
|
.insert(hash_session(&session_id), expires_at);
|
|
let secure = if state.config.secure_cookie {
|
|
"; Secure"
|
|
} else {
|
|
""
|
|
};
|
|
let cookie = format!(
|
|
"{SESSION_COOKIE}={session_id}; Path=/; HttpOnly; SameSite=Strict; Max-Age={}{}",
|
|
SESSION_TTL.as_secs(),
|
|
secure
|
|
);
|
|
let mut response = Json(SessionResponse {
|
|
authenticated: true,
|
|
})
|
|
.into_response();
|
|
response.headers_mut().insert(
|
|
header::SET_COOKIE,
|
|
HeaderValue::from_str(&cookie).map_err(|_| ApiError::bad_request("无法创建登录会话"))?,
|
|
);
|
|
Ok(response)
|
|
}
|
|
|
|
async fn session_status(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
) -> Json<SessionResponse> {
|
|
Json(SessionResponse {
|
|
authenticated: authenticated(&state, &headers).await,
|
|
})
|
|
}
|
|
|
|
async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
|
|
if let Some(session_id) = session_cookie(&headers) {
|
|
state
|
|
.sessions
|
|
.write()
|
|
.await
|
|
.remove(&hash_session(&session_id));
|
|
}
|
|
let secure = if state.config.secure_cookie {
|
|
"; Secure"
|
|
} else {
|
|
""
|
|
};
|
|
let cookie = format!("{SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0{secure}");
|
|
let mut response = StatusCode::NO_CONTENT.into_response();
|
|
if let Ok(value) = HeaderValue::from_str(&cookie) {
|
|
response.headers_mut().insert(header::SET_COOKIE, value);
|
|
}
|
|
response
|
|
}
|
|
|
|
async fn list_deployments(
|
|
State(state): State<AppState>,
|
|
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
|
|
.read()
|
|
.await
|
|
.values()
|
|
.filter(|record| record.public.status != DeploymentStatus::Stopped)
|
|
.map(|record| record.public.clone())
|
|
.collect();
|
|
deployments.sort_by(|left, right| right.created_at.cmp(&left.created_at));
|
|
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_record_id(&id)?;
|
|
refresh_running_health(&state).await;
|
|
let deployment = state
|
|
.deployments
|
|
.read()
|
|
.await
|
|
.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))
|
|
}
|
|
|
|
async fn refresh_running_health(state: &AppState) {
|
|
let probes: Vec<_> = state
|
|
.deployments
|
|
.read()
|
|
.await
|
|
.iter()
|
|
.filter_map(|(id, record)| {
|
|
(record.public.status == DeploymentStatus::Running)
|
|
.then(|| record.public.web_url.clone().map(|url| (id.clone(), url)))
|
|
.flatten()
|
|
})
|
|
.collect();
|
|
if probes.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let http = match reqwest::Client::builder()
|
|
.redirect(reqwest::redirect::Policy::none())
|
|
.timeout(Duration::from_secs(3))
|
|
.build()
|
|
{
|
|
Ok(http) => http,
|
|
Err(_) => return,
|
|
};
|
|
let mut tasks = tokio::task::JoinSet::new();
|
|
for (id, url) in probes {
|
|
let http = http.clone();
|
|
tasks.spawn(async move {
|
|
let healthy = http
|
|
.get(url)
|
|
.send()
|
|
.await
|
|
.is_ok_and(|response| response.status().is_success());
|
|
(id, healthy)
|
|
});
|
|
}
|
|
|
|
let mut changed = false;
|
|
while let Some(Ok((id, healthy))) = tasks.join_next().await {
|
|
let mut deployments = state.deployments.write().await;
|
|
let Some(record) = deployments.get_mut(&id) else {
|
|
continue;
|
|
};
|
|
let next = if healthy {
|
|
HealthStatus::Healthy
|
|
} else {
|
|
HealthStatus::Unhealthy
|
|
};
|
|
if record.public.status == DeploymentStatus::Running && record.public.health != next {
|
|
record.public.health = next;
|
|
record.public.updated_at = unix_now();
|
|
record.public.message = Some(if healthy {
|
|
"预览实例运行正常".to_string()
|
|
} else {
|
|
"预览实例 Web 健康检查失败".to_string()
|
|
});
|
|
changed = true;
|
|
}
|
|
}
|
|
if changed && let Err(cause) = persist_deployments(state).await {
|
|
error!(error = %cause, "failed to persist preview health refresh");
|
|
}
|
|
}
|
|
|
|
async fn create_deployment(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
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: None,
|
|
branch: branch.clone(),
|
|
commit_hash: commit_hash.clone(),
|
|
resolved_commit: None,
|
|
status: DeploymentStatus::Queued,
|
|
health: HealthStatus::Pending,
|
|
web_port: None,
|
|
web_url: None,
|
|
jenkins_build_url: None,
|
|
created_at: now,
|
|
updated_at: now,
|
|
message: Some("等待 Jenkins 调度".to_string()),
|
|
can_uninstall: false,
|
|
};
|
|
{
|
|
let mut deployments = state.deployments.write().await;
|
|
if deployments.get(&id).is_some_and(|record| {
|
|
matches!(
|
|
record.public.status,
|
|
DeploymentStatus::Queued
|
|
| DeploymentStatus::Building
|
|
| DeploymentStatus::Deploying
|
|
| DeploymentStatus::Uninstalling
|
|
)
|
|
}) {
|
|
return Err(ApiError::conflict("该分支已有进行中的部署操作"));
|
|
}
|
|
deployments.insert(
|
|
id.clone(),
|
|
DeploymentRecord {
|
|
public: deployment.clone(),
|
|
instance_id: id.clone(),
|
|
operation: Operation::Deploy,
|
|
queue_url: None,
|
|
},
|
|
);
|
|
}
|
|
persist_deployments(&state)
|
|
.await
|
|
.map_err(ApiError::upstream)?;
|
|
|
|
let reference = match state
|
|
.jenkins
|
|
.trigger(BuildAction::Deploy {
|
|
deployment_id: &id,
|
|
branch: &branch,
|
|
commit_hash: commit_hash.as_deref(),
|
|
})
|
|
.await
|
|
{
|
|
Ok(reference) => reference,
|
|
Err(cause) => {
|
|
mark_failed(&state, &id, "无法触发 Jenkins 构建").await;
|
|
warn!(deployment_id = %id, error = %cause, "failed to trigger preview deployment");
|
|
return Err(ApiError::upstream("无法触发 Jenkins 构建"));
|
|
}
|
|
};
|
|
record_queue_reference(&state, &id, &reference).await;
|
|
spawn_monitor(state, id, reference);
|
|
Ok((StatusCode::ACCEPTED, Json(deployment)))
|
|
}
|
|
|
|
async fn uninstall_deployment(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Path(id): Path<String>,
|
|
) -> Result<(StatusCode, Json<Deployment>), ApiError> {
|
|
require_session(&state, &headers).await?;
|
|
validate_record_id(&id)?;
|
|
let (record_key, instance_id, branch) = {
|
|
let mut deployments = state.deployments.write().await;
|
|
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
|
|
| DeploymentStatus::Building
|
|
| DeploymentStatus::Deploying
|
|
| DeploymentStatus::Uninstalling
|
|
) {
|
|
return Err(ApiError::conflict("该实例当前不能卸载"));
|
|
}
|
|
if !record.public.can_uninstall {
|
|
return Err(ApiError::conflict("该实例当前没有可卸载的容器"));
|
|
}
|
|
record.public.status = DeploymentStatus::Uninstalling;
|
|
record.public.health = HealthStatus::Pending;
|
|
record.public.can_uninstall = false;
|
|
record.public.updated_at = unix_now();
|
|
record.public.message = Some("等待 Jenkins 卸载".to_string());
|
|
record.operation = Operation::Uninstall;
|
|
record.queue_url = None;
|
|
(
|
|
record_key.clone(),
|
|
record.instance_id.clone(),
|
|
record.public.branch.clone(),
|
|
)
|
|
};
|
|
persist_deployments(&state)
|
|
.await
|
|
.map_err(ApiError::upstream)?;
|
|
|
|
let reference = match state
|
|
.jenkins
|
|
.trigger(BuildAction::Uninstall {
|
|
deployment_id: &instance_id,
|
|
branch: &branch,
|
|
})
|
|
.await
|
|
{
|
|
Ok(reference) => reference,
|
|
Err(cause) => {
|
|
let mut deployments = state.deployments.write().await;
|
|
if let Some(record) = deployments.get_mut(&record_key) {
|
|
record.public.status = DeploymentStatus::Failed;
|
|
record.public.health = HealthStatus::Unknown;
|
|
record.public.can_uninstall = true;
|
|
record.public.updated_at = unix_now();
|
|
record.public.message = Some("无法触发 Jenkins 卸载".to_string());
|
|
}
|
|
drop(deployments);
|
|
if let Err(error) = persist_deployments(&state).await {
|
|
error!(deployment_id = %id, %error, "failed to persist uninstall trigger failure");
|
|
}
|
|
warn!(deployment_id = %id, error = %cause, "failed to trigger preview uninstall");
|
|
return Err(ApiError::upstream("无法触发 Jenkins 卸载"));
|
|
}
|
|
};
|
|
record_queue_reference(&state, &id, &reference).await;
|
|
let deployment = state
|
|
.deployments
|
|
.read()
|
|
.await
|
|
.get(&record_key)
|
|
.expect("deployment exists")
|
|
.public
|
|
.clone();
|
|
spawn_monitor(state, record_key, reference);
|
|
Ok((StatusCode::ACCEPTED, Json(deployment)))
|
|
}
|
|
|
|
fn spawn_monitor(state: AppState, id: String, reference: BuildReference) {
|
|
tokio::spawn(async move {
|
|
match tokio::time::timeout(
|
|
Duration::from_secs(6 * 60 * 60),
|
|
monitor_build(&state, &id, reference),
|
|
)
|
|
.await
|
|
{
|
|
Ok(Ok(())) => {}
|
|
Ok(Err(cause)) => {
|
|
error!(deployment_id = %id, error = %cause, "preview Jenkins monitor failed");
|
|
mark_failed(&state, &id, "Jenkins 构建状态不可用").await;
|
|
}
|
|
Err(_) => {
|
|
error!(deployment_id = %id, "preview Jenkins monitor timed out");
|
|
mark_failed(&state, &id, "Jenkins 构建等待超时").await;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
fn resume_monitors(state: &AppState) {
|
|
let state = state.clone();
|
|
tokio::spawn(async move {
|
|
prune_expired_records(&state).await;
|
|
let pending: Vec<_> = state
|
|
.deployments
|
|
.read()
|
|
.await
|
|
.iter()
|
|
.filter_map(|(id, record)| {
|
|
matches!(
|
|
record.public.status,
|
|
DeploymentStatus::Queued
|
|
| DeploymentStatus::Building
|
|
| DeploymentStatus::Deploying
|
|
| DeploymentStatus::Uninstalling
|
|
)
|
|
.then(|| (id.clone(), record.queue_url.clone()))
|
|
})
|
|
.collect();
|
|
for (id, queue_url) in pending {
|
|
let Some(queue_url) = queue_url else {
|
|
mark_failed(&state, &id, "服务重启后缺少 Jenkins 队列引用").await;
|
|
continue;
|
|
};
|
|
match state.jenkins.restore_reference(&queue_url) {
|
|
Ok(reference) => spawn_monitor(state.clone(), id, reference),
|
|
Err(cause) => {
|
|
warn!(deployment_id = %id, error = %cause, "invalid persisted Jenkins queue reference");
|
|
mark_failed(&state, &id, "Jenkins 队列引用无效").await;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
async fn record_queue_reference(state: &AppState, id: &str, reference: &BuildReference) {
|
|
if let Some(record) = state.deployments.write().await.get_mut(id) {
|
|
record.queue_url = Some(reference.as_str().to_string());
|
|
}
|
|
if let Err(error) = persist_deployments(state).await {
|
|
error!(deployment_id = %id, %error, "failed to persist Jenkins queue reference");
|
|
}
|
|
}
|
|
|
|
async fn monitor_build(
|
|
state: &AppState,
|
|
id: &str,
|
|
reference: BuildReference,
|
|
) -> Result<(), String> {
|
|
let outcome = state
|
|
.jenkins
|
|
.wait_for_outcome(reference, |build| {
|
|
let state = state.clone();
|
|
let id = id.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.public_url);
|
|
record.public.updated_at = unix_now();
|
|
record.public.message = Some(match record.operation {
|
|
Operation::Deploy => "Jenkins 正在构建".to_string(),
|
|
Operation::Uninstall => "Jenkins 正在卸载".to_string(),
|
|
});
|
|
}
|
|
drop(deployments);
|
|
if let Err(cause) = persist_deployments(&state).await {
|
|
error!(deployment_id = %id, error = %cause, "failed to persist preview build transition");
|
|
}
|
|
}
|
|
})
|
|
.await?;
|
|
apply_outcome(state, id, outcome).await;
|
|
Ok(())
|
|
}
|
|
|
|
async fn apply_outcome(state: &AppState, id: &str, outcome: JenkinsOutcome) {
|
|
let mut deployments = state.deployments.write().await;
|
|
let Some(record) = deployments.get_mut(id) else {
|
|
return;
|
|
};
|
|
record.public.updated_at = unix_now();
|
|
if outcome.cancelled {
|
|
record.public.status = DeploymentStatus::Cancelled;
|
|
record.public.health = HealthStatus::Unknown;
|
|
record.public.can_uninstall = record.operation == Operation::Uninstall;
|
|
record.public.message = Some("Jenkins 构建已取消".to_string());
|
|
} else if !outcome.success {
|
|
record.public.status = DeploymentStatus::Failed;
|
|
record.public.health = HealthStatus::Unknown;
|
|
record.public.can_uninstall = record.operation == Operation::Uninstall;
|
|
record.public.message = Some("Jenkins 构建失败".to_string());
|
|
} else if let Some(result) = outcome.result {
|
|
let expected_action = match record.operation {
|
|
Operation::Deploy => "DEPLOY",
|
|
Operation::Uninstall => "UNINSTALL",
|
|
};
|
|
let expected_project = format!("genarrative-{id}");
|
|
if result.schema_version != Some(1)
|
|
|| result.action.as_deref() != Some(expected_action)
|
|
|| result.deployment_id.as_deref() != Some(id)
|
|
|| result.project_name.as_deref() != Some(expected_project.as_str())
|
|
|| result.branch.as_deref() != Some(record.public.branch.as_str())
|
|
{
|
|
record.public.status = DeploymentStatus::Failed;
|
|
record.public.health = HealthStatus::Unknown;
|
|
record.public.message = Some("Jenkins 返回了不匹配的部署结果".to_string());
|
|
} else if result
|
|
.resolved_commit
|
|
.as_deref()
|
|
.is_some_and(|value| validate_commit(value).is_err())
|
|
{
|
|
record.public.status = DeploymentStatus::Failed;
|
|
record.public.health = HealthStatus::Unknown;
|
|
record.public.message = Some("Jenkins 返回了无效 commit".to_string());
|
|
} else if result
|
|
.web_url
|
|
.as_deref()
|
|
.is_some_and(|value| !is_safe_web_url(value, &state.config.preview_web_host))
|
|
{
|
|
record.public.status = DeploymentStatus::Failed;
|
|
record.public.health = HealthStatus::Unknown;
|
|
record.public.message = Some("Jenkins 返回了无效 Web 地址".to_string());
|
|
} else if result
|
|
.web_port
|
|
.is_some_and(|value| !(8400..=8499).contains(&value))
|
|
|| result
|
|
.web_url
|
|
.as_deref()
|
|
.zip(result.web_port)
|
|
.is_some_and(|(url, port)| {
|
|
Url::parse(url).ok().and_then(|url| url.port()) != Some(port)
|
|
})
|
|
{
|
|
record.public.status = DeploymentStatus::Failed;
|
|
record.public.health = HealthStatus::Unknown;
|
|
record.public.message = Some("Jenkins 返回了无效 Web 端口".to_string());
|
|
} else if record.operation == Operation::Deploy
|
|
&& (result.resolved_commit.is_none()
|
|
|| result.web_port.is_none()
|
|
|| result.web_url.is_none()
|
|
|| result.phase.as_deref() != Some("RUNNING")
|
|
|| result.health_status.as_deref() != Some("HEALTHY"))
|
|
{
|
|
record.public.status = DeploymentStatus::Failed;
|
|
record.public.health = HealthStatus::Unknown;
|
|
record.public.message = Some("Jenkins 部署结果缺少运行或健康证明".to_string());
|
|
} else if record.operation == Operation::Uninstall
|
|
&& (result.phase.as_deref() != Some("UNINSTALLED")
|
|
|| result.health_status.as_deref() != Some("UNINSTALLED"))
|
|
{
|
|
record.public.status = DeploymentStatus::Failed;
|
|
record.public.health = HealthStatus::Unknown;
|
|
record.public.can_uninstall = true;
|
|
record.public.message = Some("Jenkins 卸载结果缺少完成证明".to_string());
|
|
} else {
|
|
if let Some(value) = result.resolved_commit {
|
|
record.public.resolved_commit = Some(value.to_ascii_lowercase());
|
|
}
|
|
if let Some(value) = result.web_url {
|
|
record.public.web_url = Some(value);
|
|
}
|
|
if let Some(value) = result.web_port {
|
|
record.public.web_port = Some(value);
|
|
}
|
|
if let Some(value) = result.health {
|
|
record.public.health = value;
|
|
}
|
|
if let Some(value) = result.message {
|
|
record.public.message = Some(value);
|
|
}
|
|
if let Some(value) = result.status {
|
|
record.public.status = value;
|
|
}
|
|
if let Some(value) = result.phase.as_deref().and_then(map_artifact_status) {
|
|
record.public.status = value;
|
|
}
|
|
if let Some(value) = result
|
|
.health_status
|
|
.as_deref()
|
|
.and_then(map_artifact_health)
|
|
{
|
|
record.public.health = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
if outcome.success && !matches!(record.public.status, DeploymentStatus::Failed) {
|
|
match record.operation {
|
|
Operation::Deploy => {
|
|
if !matches!(
|
|
record.public.status,
|
|
DeploymentStatus::Running
|
|
| DeploymentStatus::Failed
|
|
| DeploymentStatus::Cancelled
|
|
) {
|
|
record.public.status = DeploymentStatus::Running;
|
|
}
|
|
if record.public.health == HealthStatus::Pending {
|
|
record.public.health = HealthStatus::Unknown;
|
|
}
|
|
record.public.can_uninstall = record.public.status == DeploymentStatus::Running;
|
|
if record.public.message.is_none() {
|
|
record.public.message = Some("预览实例已发布".to_string());
|
|
}
|
|
}
|
|
Operation::Uninstall => {
|
|
record.public.status = DeploymentStatus::Stopped;
|
|
record.public.health = HealthStatus::Unknown;
|
|
record.public.web_port = None;
|
|
record.public.web_url = None;
|
|
record.public.can_uninstall = false;
|
|
if record.public.message.is_none() {
|
|
record.public.message = Some("预览实例已卸载".to_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
record.queue_url = None;
|
|
drop(deployments);
|
|
if let Err(cause) = persist_deployments(state).await {
|
|
error!(deployment_id = %id, error = %cause, "failed to persist preview outcome");
|
|
}
|
|
}
|
|
|
|
async fn mark_failed(state: &AppState, id: &str, message: &str) {
|
|
if let Some(record) = state.deployments.write().await.get_mut(id) {
|
|
record.public.status = DeploymentStatus::Failed;
|
|
record.public.health = HealthStatus::Unknown;
|
|
record.public.updated_at = unix_now();
|
|
record.public.message = Some(message.to_string());
|
|
}
|
|
if let Err(cause) = persist_deployments(state).await {
|
|
error!(deployment_id = %id, error = %cause, "failed to persist preview failure");
|
|
}
|
|
}
|
|
|
|
fn load_deployments(config: &Config) -> Result<HashMap<String, DeploymentRecord>, String> {
|
|
let bytes = match std::fs::read(&config.state_file) {
|
|
Ok(bytes) => bytes,
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(HashMap::new()),
|
|
Err(error) => return Err(format!("无法读取预览部署状态文件: {error}")),
|
|
};
|
|
let persisted: PersistedState =
|
|
serde_json::from_slice(&bytes).map_err(|_| "预览部署状态文件格式无效".to_string())?;
|
|
if persisted.schema_version != 1 {
|
|
return Err("预览部署状态文件 schemaVersion 不受支持".to_string());
|
|
}
|
|
let mut deployments = HashMap::new();
|
|
for mut record in persisted.deployments {
|
|
let branch = validate_branch(&record.public.branch)
|
|
.map_err(|_| "状态文件包含无效分支名".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())?;
|
|
}
|
|
if record
|
|
.public
|
|
.web_url
|
|
.as_deref()
|
|
.is_some_and(|value| !is_safe_web_url(value, &config.preview_web_host))
|
|
{
|
|
return Err("状态文件包含无效 Web 地址".to_string());
|
|
}
|
|
let url_port = record
|
|
.public
|
|
.web_url
|
|
.as_deref()
|
|
.and_then(|value| Url::parse(value).ok())
|
|
.and_then(|url| url.port());
|
|
if record
|
|
.public
|
|
.web_port
|
|
.is_some_and(|value| !(8400..=8499).contains(&value))
|
|
|| record
|
|
.public
|
|
.web_port
|
|
.zip(url_port)
|
|
.is_some_and(|(saved, parsed)| saved != parsed)
|
|
{
|
|
return Err("状态文件包含无效 Web 端口".to_string());
|
|
}
|
|
if record.public.web_port.is_none() {
|
|
record.public.web_port = url_port;
|
|
}
|
|
if deployments
|
|
.insert(record.instance_id.clone(), record)
|
|
.is_some()
|
|
{
|
|
return Err("状态文件包含重复预览实例 ID".to_string());
|
|
}
|
|
}
|
|
Ok(deployments)
|
|
}
|
|
|
|
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.created_at.cmp(&right.public.created_at));
|
|
let bytes = serde_json::to_vec_pretty(&PersistedState {
|
|
schema_version: 1,
|
|
deployments,
|
|
})
|
|
.map_err(|_| "无法序列化预览部署状态".to_string())?;
|
|
let parent = state
|
|
.config
|
|
.state_file
|
|
.parent()
|
|
.ok_or_else(|| "状态文件缺少父目录".to_string())?;
|
|
let temp_path = parent.join(format!(
|
|
".preview-deployer-state-{}.tmp",
|
|
Uuid::new_v4().simple()
|
|
));
|
|
tokio::fs::write(&temp_path, bytes)
|
|
.await
|
|
.map_err(|error| format!("无法写入预览部署临时状态: {error}"))?;
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
tokio::fs::set_permissions(&temp_path, std::fs::Permissions::from_mode(0o600))
|
|
.await
|
|
.map_err(|error| format!("无法限制预览部署状态权限: {error}"))?;
|
|
}
|
|
if let Err(error) = tokio::fs::rename(&temp_path, &state.config.state_file).await {
|
|
let _ = tokio::fs::remove_file(&temp_path).await;
|
|
return Err(format!("无法原子安装预览部署状态: {error}"));
|
|
}
|
|
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(())
|
|
} else {
|
|
Err(ApiError::unauthorized())
|
|
}
|
|
}
|
|
|
|
async fn authenticated(state: &AppState, headers: &HeaderMap) -> bool {
|
|
let Some(session_id) = session_cookie(headers) else {
|
|
return false;
|
|
};
|
|
let key = hash_session(&session_id);
|
|
let now = unix_now();
|
|
let mut sessions = state.sessions.write().await;
|
|
sessions.retain(|_, expiry| *expiry > now);
|
|
sessions.get(&key).is_some_and(|expiry| *expiry > now)
|
|
}
|
|
|
|
fn session_cookie(headers: &HeaderMap) -> Option<String> {
|
|
headers
|
|
.get(header::COOKIE)?
|
|
.to_str()
|
|
.ok()?
|
|
.split(';')
|
|
.find_map(|part| {
|
|
let (name, value) = part.trim().split_once('=')?;
|
|
(name == SESSION_COOKIE
|
|
&& !value.is_empty()
|
|
&& value.len() <= 128
|
|
&& value.bytes().all(|byte| byte.is_ascii_alphanumeric()))
|
|
.then(|| value.to_string())
|
|
})
|
|
}
|
|
|
|
fn hash_session(value: &str) -> String {
|
|
format!("{:x}", Sha256::digest(value.as_bytes()))
|
|
}
|
|
|
|
fn validate_branch(raw: &str) -> Result<String, ApiError> {
|
|
let value = raw.trim();
|
|
if value.is_empty()
|
|
|| value.len() > 160
|
|
|| value.starts_with('-')
|
|
|| value.starts_with('/')
|
|
|| value.ends_with('/')
|
|
|| value.contains("..")
|
|
|| value.contains("@{")
|
|
|| value.ends_with('.')
|
|
|| value.ends_with(".lock")
|
|
|| !value
|
|
.bytes()
|
|
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'_' | b'-' | b'.'))
|
|
{
|
|
return Err(ApiError::bad_request("分支名格式无效"));
|
|
}
|
|
Ok(value.to_string())
|
|
}
|
|
|
|
fn validate_commit(raw: &str) -> Result<String, ApiError> {
|
|
let value = raw.trim();
|
|
if !(7..=40).contains(&value.len()) || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
|
return Err(ApiError::bad_request(
|
|
"commit hash 必须是 7 到 40 位十六进制字符",
|
|
));
|
|
}
|
|
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-")
|
|
|| !value[8..]
|
|
.bytes()
|
|
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
|
|
{
|
|
return Err(ApiError::bad_request("部署 ID 格式无效"));
|
|
}
|
|
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 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> {
|
|
match value.to_ascii_uppercase().as_str() {
|
|
"QUEUED" => Some(DeploymentStatus::Queued),
|
|
"BUILDING" => Some(DeploymentStatus::Building),
|
|
"DEPLOYING" => Some(DeploymentStatus::Deploying),
|
|
"RUNNING" | "UNHEALTHY" => Some(DeploymentStatus::Running),
|
|
"UNINSTALLING" => Some(DeploymentStatus::Uninstalling),
|
|
"UNINSTALLED" | "STOPPED" | "NOT_FOUND" => Some(DeploymentStatus::Stopped),
|
|
"FAILED" => Some(DeploymentStatus::Failed),
|
|
"CANCELLED" => Some(DeploymentStatus::Cancelled),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn map_artifact_health(value: &str) -> Option<HealthStatus> {
|
|
match value.to_ascii_uppercase().as_str() {
|
|
"PENDING" => Some(HealthStatus::Pending),
|
|
"HEALTHY" => Some(HealthStatus::Healthy),
|
|
"UNHEALTHY" => Some(HealthStatus::Unhealthy),
|
|
"UNKNOWN" | "UNINSTALLED" | "NOT_FOUND" => Some(HealthStatus::Unknown),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn is_safe_web_url(value: &str, expected_host: &str) -> bool {
|
|
url::Url::parse(value).is_ok_and(|url| {
|
|
url.scheme() == "http"
|
|
&& url.host_str() == Some(expected_host)
|
|
&& url.username().is_empty()
|
|
&& url.password().is_none()
|
|
&& url.path() == "/"
|
|
&& url.query().is_none()
|
|
&& url.fragment().is_none()
|
|
&& url.port().is_some_and(|port| (8400..=8499).contains(&port))
|
|
})
|
|
}
|
|
|
|
fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
|
|
let mut difference = left.len() ^ right.len();
|
|
let length = left.len().max(right.len());
|
|
for index in 0..length {
|
|
difference |= usize::from(*left.get(index).unwrap_or(&0) ^ *right.get(index).unwrap_or(&0));
|
|
}
|
|
difference == 0
|
|
}
|
|
|
|
fn unix_now() -> u64 {
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_secs()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|