合并 master 的预览部署控制面
Project CI / Repository checks (pull_request) Successful in 55s
Project CI / Frontend tests (pull_request) Successful in 2m56s
Project CI / Backend tests (pull_request) Successful in 3m35s
Project CI / Native shell tests (pull_request) Successful in 17m44s

保留 Spine 序列帧与 Jenkins 容器预览决策记录

合入 preview-deployer-web、preview-deployer-server 及部署脚本
This commit is contained in:
2026-08-15 17:48:16 +08:00
39 changed files with 5433 additions and 2 deletions
+34
View File
@@ -2233,6 +2233,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "http-range-header"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[package]]
name = "httparse"
version = "1.10.1"
@@ -4315,6 +4321,25 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "preview-deployer-server"
version = "0.1.0"
dependencies = [
"axum",
"http-body-util",
"reqwest",
"serde",
"serde_json",
"sha2",
"tokio",
"tower",
"tower-http",
"tracing",
"tracing-subscriber",
"url",
"uuid",
]
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
@@ -6318,10 +6343,19 @@ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"bitflags 2.13.0",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"http-range-header",
"httpdate",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
"tower",
"tower-layer",
"tower-service",
+1
View File
@@ -48,6 +48,7 @@ members = [
"crates/platform-speech",
"crates/platform-editor-agent",
"crates/pingora-gateway",
"crates/preview-deployer-server",
"crates/server-manager-panel",
"crates/shared-contracts",
"crates/shared-kernel",
@@ -0,0 +1,22 @@
[package]
name = "preview-deployer-server"
edition.workspace = true
version.workspace = true
license.workspace = true
[dependencies]
axum = { workspace = true }
reqwest = { workspace = true, features = ["json", "rustls-tls"] }
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "time", "sync", "signal"] }
tower-http = { workspace = true, features = ["fs", "trace"] }
tracing = { workspace = true }
tracing-subscriber = { workspace = true, features = ["env-filter"] }
url = { workspace = true }
uuid = { workspace = true, features = ["v4"] }
[dev-dependencies]
http-body-util = { workspace = true }
tower = { workspace = true, features = ["util"] }
@@ -0,0 +1,177 @@
use std::{env, fmt, path::PathBuf, time::Duration};
use url::Url;
const JOB_PATH: &str = "job/shared/job/Genarrative-Preview-Deployer/";
#[derive(Clone)]
pub struct Config {
pub bind_address: String,
pub jenkins_root_url: Url,
pub jenkins_base_url: Url,
pub jenkins_username: String,
pub jenkins_api_token: String,
pub access_token: String,
pub allowed_hosts: Vec<String>,
pub allowed_origins: Vec<String>,
pub preview_web_host: String,
pub secure_cookie: bool,
pub static_dir: Option<PathBuf>,
pub state_file: PathBuf,
pub poll_interval: Duration,
}
impl fmt::Debug for Config {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("Config")
.field("bind_address", &self.bind_address)
.field("jenkins_root_url", &self.jenkins_root_url)
.field("jenkins_base_url", &self.jenkins_base_url)
.field(
"jenkins_username_configured",
&!self.jenkins_username.is_empty(),
)
.field(
"jenkins_api_token_configured",
&!self.jenkins_api_token.is_empty(),
)
.field("access_token_configured", &!self.access_token.is_empty())
.field("allowed_hosts", &self.allowed_hosts)
.field("allowed_origins", &self.allowed_origins)
.field("preview_web_host", &self.preview_web_host)
.field("secure_cookie", &self.secure_cookie)
.field("static_dir", &self.static_dir)
.field("state_file", &self.state_file)
.field("poll_interval", &self.poll_interval)
.finish()
}
}
impl Config {
pub fn from_env() -> Result<Self, String> {
let bind_address = required("GENARRATIVE_PREVIEW_DEPLOYER_BIND")?;
let mut jenkins_root_url = Url::parse(&required(
"GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_BASE_URL",
)?)
.map_err(|_| "GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_BASE_URL 不是有效 URL".to_string())?;
if !matches!(jenkins_root_url.scheme(), "http" | "https")
|| jenkins_root_url.host_str().is_none()
{
return Err("Jenkins base URL 只允许 http/https 绝对 URL".to_string());
}
if !jenkins_root_url.username().is_empty() || jenkins_root_url.password().is_some() {
return Err("Jenkins base URL 不能包含用户名或密码".to_string());
}
jenkins_root_url.set_query(None);
jenkins_root_url.set_fragment(None);
if !jenkins_root_url.path().ends_with('/') {
let path = format!("{}/", jenkins_root_url.path());
jenkins_root_url.set_path(&path);
}
let jenkins_base_url = jenkins_root_url
.join(JOB_PATH)
.map_err(|_| "无法构造固定 Jenkins Job URL".to_string())?;
let access_token = required("GENARRATIVE_PREVIEW_DEPLOYER_ACCESS_TOKEN")?;
if access_token.len() < 24 {
return Err("GENARRATIVE_PREVIEW_DEPLOYER_ACCESS_TOKEN 至少需要 24 个字符".to_string());
}
let allowed_hosts = csv_required("GENARRATIVE_PREVIEW_DEPLOYER_ALLOWED_HOSTS")?;
let allowed_origins = csv_required("GENARRATIVE_PREVIEW_DEPLOYER_ALLOWED_ORIGINS")?;
let preview_web_host = required("GENARRATIVE_PREVIEW_DEPLOYER_WEB_HOST")?;
if preview_web_host.contains(['/', ':', '@']) {
return Err(
"GENARRATIVE_PREVIEW_DEPLOYER_WEB_HOST 只能填写不带协议和端口的主机名或 IP"
.to_string(),
);
}
for origin in &allowed_origins {
let parsed =
Url::parse(origin).map_err(|_| format!("无效 allowed origin: {origin}"))?;
if !matches!(parsed.scheme(), "http" | "https")
|| parsed.host_str().is_none()
|| parsed.path() != "/"
|| parsed.query().is_some()
|| parsed.fragment().is_some()
{
return Err(format!(
"allowed origin 必须是无路径的 http/https 源: {origin}"
));
}
}
let secure_cookie = env::var("GENARRATIVE_PREVIEW_DEPLOYER_SECURE_COOKIE")
.map(|value| value != "false")
.unwrap_or_else(|_| {
allowed_origins
.iter()
.all(|origin| origin.starts_with("https://"))
});
let static_dir = env::var_os("GENARRATIVE_PREVIEW_DEPLOYER_STATIC_DIR")
.filter(|value| !value.is_empty())
.map(PathBuf::from);
let state_file = PathBuf::from(required("GENARRATIVE_PREVIEW_DEPLOYER_STATE_FILE")?);
validate_state_file(&state_file)?;
Ok(Self {
bind_address,
jenkins_root_url,
jenkins_base_url,
jenkins_username: required("GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_USERNAME")?,
jenkins_api_token: required("GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_API_TOKEN")?,
access_token,
allowed_hosts,
allowed_origins,
preview_web_host,
secure_cookie,
static_dir,
state_file,
poll_interval: Duration::from_secs(2),
})
}
}
fn validate_state_file(path: &std::path::Path) -> Result<(), String> {
if !path.is_absolute() || path == std::path::Path::new("/") || path.file_name().is_none() {
return Err(
"GENARRATIVE_PREVIEW_DEPLOYER_STATE_FILE 必须是非根目录绝对文件路径".to_string(),
);
}
let parent = path
.parent()
.ok_or_else(|| "状态文件缺少父目录".to_string())?;
let metadata = std::fs::symlink_metadata(parent)
.map_err(|_| "预览部署状态文件父目录必须已存在".to_string())?;
if !metadata.is_dir() || metadata.file_type().is_symlink() {
return Err("预览部署状态文件父目录必须是普通目录且不能是符号链接".to_string());
}
if let Ok(metadata) = std::fs::symlink_metadata(path) {
if !metadata.is_file() || metadata.file_type().is_symlink() {
return Err("预览部署状态文件必须是普通文件且不能是符号链接".to_string());
}
}
Ok(())
}
fn required(name: &str) -> Result<String, String> {
env::var(name)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.ok_or_else(|| format!("缺少必需环境变量 {name}"))
}
fn csv_required(name: &str) -> Result<Vec<String>, String> {
let values: Vec<_> = required(name)?
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.collect();
if values.is_empty() {
Err(format!("{name} 至少需要一个值"))
} else {
Ok(values)
}
}
@@ -0,0 +1,297 @@
use std::{future::Future, time::Duration};
use reqwest::{Client, StatusCode, header};
use serde::Deserialize;
use url::Url;
use crate::{Config, DeploymentStatus, HealthStatus};
#[derive(Clone)]
pub struct JenkinsClient {
http: Client,
root_url: Url,
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,
}
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>,
#[serde(alias = "sourceBranch")]
pub branch: Option<String>,
#[serde(alias = "sourceCommit")]
pub resolved_commit: Option<String>,
pub status: Option<DeploymentStatus>,
pub health: Option<HealthStatus>,
pub phase: Option<String>,
pub health_status: Option<String>,
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(),
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(&str) -> Fut,
Fut: Future<Output = ()>,
{
let build_url = loop {
let item_url = reference
.queue_url
.join("api/json")
.map_err(|error| error.to_string())?;
let item: QueueItem = self.get_json(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;
};
on_build(build_url.as_str()).await;
let successful = loop {
let state_url = build_url
.join("api/json")
.map_err(|error| error.to_string())?;
let state: BuildState = self.get_json(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(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()));
}
response
.json()
.await
.map_err(|_| "Jenkins 状态响应格式无效".to_string())
}
fn resolve_trusted_url(&self, value: &str) -> Result<Url, String> {
let url = Url::parse(value)
.or_else(|_| self.job_url.join(value))
.map_err(|_| "Jenkins 返回了无效 URL".to_string())?;
self.ensure_same_origin(&url)?;
Ok(url)
}
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())
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,42 @@
use preview_deployer_server::{AppState, Config, build_router};
use tracing::info;
use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info,tower_http=info")),
)
.init();
let config = Config::from_env().unwrap_or_else(|error| {
eprintln!("预览部署服务配置无效: {error}");
std::process::exit(2);
});
let listener = tokio::net::TcpListener::bind(&config.bind_address)
.await
.unwrap_or_else(|error| {
eprintln!("预览部署服务监听失败: {error}");
std::process::exit(2);
});
let address = listener
.local_addr()
.expect("bound listener has local address");
let state = AppState::new(config).unwrap_or_else(|error| {
eprintln!("预览部署服务初始化失败: {error}");
std::process::exit(2);
});
info!(%address, "preview deployer server started");
axum::serve(listener, build_router(state))
.with_graceful_shutdown(async {
let _ = tokio::signal::ctrl_c().await;
})
.await
.unwrap_or_else(|error| {
eprintln!("预览部署服务退出: {error}");
std::process::exit(1);
});
}
File diff suppressed because it is too large Load Diff