Files
Genarrative/server-rs/crates/preview-deployer-server/src/config.rs
T
kdletters 38ae9d7d07
Project CI / Repository checks (push) Successful in 1m6s
Project CI / Frontend tests (push) Successful in 2m55s
Project CI / Backend tests (push) Successful in 3m53s
Project CI / Native shell tests (push) Successful in 15m2s
完善预览发布记录管理
发布记录改用 Jenkins 构建编号并展示

失败与停止记录增加自动清理期限

构建详情改为局域网 Jenkins 地址

同步测试、部署配置和运维文档
2026-08-17 12:42:29 +08:00

241 lines
9.7 KiB
Rust

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_public_base_url: Url,
pub jenkins_username: String,
pub jenkins_api_token: String,
pub git_remote_url: String,
pub git_ssh_command: Option<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_public_base_url", &self.jenkins_public_base_url)
.field(
"jenkins_username_configured",
&!self.jenkins_username.is_empty(),
)
.field(
"jenkins_api_token_configured",
&!self.jenkins_api_token.is_empty(),
)
.field("git_remote_url", &self.git_remote_url)
.field(
"git_ssh_command_configured",
&self.git_ssh_command.is_some(),
)
.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 mut jenkins_public_root_url = Url::parse(&required(
"GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_PUBLIC_BASE_URL",
)?)
.map_err(|_| {
"GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_PUBLIC_BASE_URL 不是有效 URL".to_string()
})?;
if !matches!(jenkins_public_root_url.scheme(), "http" | "https")
|| jenkins_public_root_url.host_str().is_none()
|| !jenkins_public_root_url.username().is_empty()
|| jenkins_public_root_url.password().is_some()
{
return Err("Jenkins public base URL 必须是无凭据的 http/https 绝对 URL".to_string());
}
jenkins_public_root_url.set_query(None);
jenkins_public_root_url.set_fragment(None);
if !jenkins_public_root_url.path().ends_with('/') {
let path = format!("{}/", jenkins_public_root_url.path());
jenkins_public_root_url.set_path(&path);
}
let jenkins_public_base_url = jenkins_public_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_public_base_url,
jenkins_username: required("GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_USERNAME")?,
jenkins_api_token: required("GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_API_TOKEN")?,
git_remote_url: validate_git_remote_url(&required(
"GENARRATIVE_PREVIEW_DEPLOYER_GIT_REMOTE_URL",
)?)?,
git_ssh_command: env::var("GENARRATIVE_PREVIEW_DEPLOYER_GIT_SSH_COMMAND")
.ok()
.map(|value| validate_git_ssh_command(value.trim()))
.transpose()?,
access_token,
allowed_hosts,
allowed_origins,
preview_web_host,
secure_cookie,
static_dir,
state_file,
poll_interval: Duration::from_secs(2),
})
}
}
fn validate_git_ssh_command(value: &str) -> Result<String, String> {
const TRUSTED_COMMAND: &str = "ssh -i /var/lib/jenkins/.ssh/genarrative-preview-readonly -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=/var/lib/jenkins/.ssh/known_hosts";
if value != TRUSTED_COMMAND {
return Err(
"GENARRATIVE_PREVIEW_DEPLOYER_GIT_SSH_COMMAND 必须使用固定只读密钥和严格主机校验参数"
.to_string(),
);
}
Ok(value.to_string())
}
fn validate_git_remote_url(value: &str) -> Result<String, String> {
const TRUSTED_REMOTE: &str = "ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git";
#[cfg(test)]
if value.starts_with("test://") {
return Ok(value.to_string());
}
if value != TRUSTED_REMOTE {
return Err(format!(
"GENARRATIVE_PREVIEW_DEPLOYER_GIT_REMOTE_URL 只允许固定内网仓库 {TRUSTED_REMOTE}"
));
}
Ok(value.to_string())
}
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)
}
}