合并 master 的预览部署控制面
保留 Spine 序列帧与 Jenkins 容器预览决策记录 合入 preview-deployer-web、preview-deployer-server 及部署脚本
This commit is contained in:
Generated
+34
@@ -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",
|
||||
|
||||
@@ -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(¶meters);
|
||||
if let Some((field, value)) = self.crumb().await? {
|
||||
let field = header::HeaderName::from_bytes(field.as_bytes())
|
||||
.map_err(|_| "Jenkins crumb header 名称无效".to_string())?;
|
||||
let value = header::HeaderValue::from_str(&value)
|
||||
.map_err(|_| "Jenkins crumb header 值无效".to_string())?;
|
||||
request = request.header(field, value);
|
||||
}
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("Jenkins trigger 请求失败: {error}"))?;
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("Jenkins trigger 返回 HTTP {}", response.status()));
|
||||
}
|
||||
let location = response
|
||||
.headers()
|
||||
.get(header::LOCATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.ok_or_else(|| "Jenkins trigger 响应缺少 queue Location".to_string())?;
|
||||
let queue_url = self.resolve_trusted_url(location)?;
|
||||
Ok(BuildReference { queue_url })
|
||||
}
|
||||
|
||||
pub fn restore_reference(&self, value: &str) -> Result<BuildReference, String> {
|
||||
Ok(BuildReference {
|
||||
queue_url: self.resolve_trusted_url(value)?,
|
||||
})
|
||||
}
|
||||
|
||||
async fn crumb(&self) -> Result<Option<(String, String)>, String> {
|
||||
let crumb_url = self
|
||||
.root_url
|
||||
.join("crumbIssuer/api/json")
|
||||
.map_err(|error| error.to_string())?;
|
||||
let response = self
|
||||
.http
|
||||
.get(crumb_url)
|
||||
.basic_auth(&self.username, Some(&self.api_token))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("Jenkins crumb 请求失败: {error}"))?;
|
||||
if matches!(
|
||||
response.status(),
|
||||
StatusCode::NOT_FOUND | StatusCode::FORBIDDEN
|
||||
) {
|
||||
return Ok(None);
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("Jenkins crumb 返回 HTTP {}", response.status()));
|
||||
}
|
||||
let crumb: Crumb = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|_| "Jenkins crumb 响应格式无效".to_string())?;
|
||||
Ok(Some((crumb.crumb_request_field, crumb.crumb)))
|
||||
}
|
||||
|
||||
pub async fn wait_for_outcome<F, Fut>(
|
||||
&self,
|
||||
reference: BuildReference,
|
||||
mut on_build: F,
|
||||
) -> Result<JenkinsOutcome, String>
|
||||
where
|
||||
F: FnMut(&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
Reference in New Issue
Block a user