Files
Genarrative/server-rs/crates/preview-deployer-server/src/lib.rs
T
kdletters e455cd593c
Project CI / Repository checks (push) Successful in 1m2s
Project CI / Frontend tests (push) Successful in 2m58s
Project CI / Backend tests (push) Successful in 3m56s
Project CI / Native shell tests (push) Successful in 14m17s
兼容历史预览构建记录
启动时回填旧记录的 Jenkins 构建编号

将旧 loopback 构建详情转换为局域网地址

补充历史记录迁移测试
2026-08-17 12:52:40 +08:00

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;