241 lines
9.7 KiB
Rust
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)
|
|
}
|
|
}
|