合并最新远程master到抠图修复分支
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 6m31s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 6m19s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m59s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 4m54s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m38s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m37s
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 6m31s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 6m19s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m59s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 4m54s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m38s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m37s
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
合并远程master的引擎资源预览与预览部署更新。 保留抠图闭环、持续集成修复和恢复面板评审改进。 合并双方决策日志并同步技能包版本及内容指纹。
This commit is contained in:
@@ -18,6 +18,7 @@ pub struct Config {
|
||||
pub allowed_hosts: Vec<String>,
|
||||
pub allowed_origins: Vec<String>,
|
||||
pub preview_web_host: String,
|
||||
pub preview_web_domain: Option<String>,
|
||||
pub secure_cookie: bool,
|
||||
pub static_dir: Option<PathBuf>,
|
||||
pub state_file: PathBuf,
|
||||
@@ -49,6 +50,7 @@ impl fmt::Debug for Config {
|
||||
.field("allowed_hosts", &self.allowed_hosts)
|
||||
.field("allowed_origins", &self.allowed_origins)
|
||||
.field("preview_web_host", &self.preview_web_host)
|
||||
.field("preview_web_domain", &self.preview_web_domain)
|
||||
.field("secure_cookie", &self.secure_cookie)
|
||||
.field("static_dir", &self.static_dir)
|
||||
.field("state_file", &self.state_file)
|
||||
@@ -116,6 +118,15 @@ impl Config {
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
let preview_web_domain = env::var("GENARRATIVE_PREVIEW_DEPLOYER_WEB_DOMAIN")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| {
|
||||
validate_preview_web_domain(&value)
|
||||
.map_err(|reason| format!("GENARRATIVE_PREVIEW_DEPLOYER_WEB_DOMAIN {reason}"))
|
||||
})
|
||||
.transpose()?;
|
||||
for origin in &allowed_origins {
|
||||
let parsed =
|
||||
Url::parse(origin).map_err(|_| format!("无效 allowed origin: {origin}"))?;
|
||||
@@ -162,6 +173,7 @@ impl Config {
|
||||
allowed_hosts,
|
||||
allowed_origins,
|
||||
preview_web_host,
|
||||
preview_web_domain,
|
||||
secure_cookie,
|
||||
static_dir,
|
||||
state_file,
|
||||
@@ -217,6 +229,28 @@ fn validate_state_file(path: &std::path::Path) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn validate_preview_web_domain(value: &str) -> Result<String, String> {
|
||||
if value.len() > 253 || !value.contains('.') {
|
||||
return Err("必须是不带协议和端口的 DNS 域名".to_string());
|
||||
}
|
||||
for label in value.split('.') {
|
||||
if label.is_empty() || label.len() > 63 {
|
||||
return Err("必须是不带协议和端口的 DNS 域名".to_string());
|
||||
}
|
||||
let bytes = label.as_bytes();
|
||||
if bytes[0] == b'-' || bytes[bytes.len() - 1] == b'-' {
|
||||
return Err("每段标签不能以短横线开头或结尾".to_string());
|
||||
}
|
||||
if !bytes
|
||||
.iter()
|
||||
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-')
|
||||
{
|
||||
return Err("只允许小写字母、数字、短横线和点号".to_string());
|
||||
}
|
||||
}
|
||||
Ok(value.to_string())
|
||||
}
|
||||
|
||||
fn required(name: &str) -> Result<String, String> {
|
||||
env::var(name)
|
||||
.ok()
|
||||
|
||||
@@ -115,6 +115,8 @@ pub struct Deployment {
|
||||
pub web_port: Option<u16>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub web_url: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub web_public_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub jenkins_build_url: Option<String>,
|
||||
pub created_at: u64,
|
||||
@@ -633,6 +635,7 @@ async fn create_deployment(
|
||||
health: HealthStatus::Pending,
|
||||
web_port: None,
|
||||
web_url: None,
|
||||
web_public_url: None,
|
||||
jenkins_build_url: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
@@ -959,6 +962,14 @@ async fn apply_outcome(state: &AppState, id: &str, outcome: JenkinsOutcome) {
|
||||
if let Some(value) = result.web_port {
|
||||
record.public.web_port = Some(value);
|
||||
}
|
||||
record.public.web_public_url = if record.public.web_url.is_some() {
|
||||
public_web_url(
|
||||
state.config.preview_web_domain.as_deref(),
|
||||
&record.instance_id,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(value) = result.health {
|
||||
record.public.health = value;
|
||||
}
|
||||
@@ -1005,6 +1016,7 @@ async fn apply_outcome(state: &AppState, id: &str, outcome: JenkinsOutcome) {
|
||||
record.public.health = HealthStatus::Unknown;
|
||||
record.public.web_port = None;
|
||||
record.public.web_url = None;
|
||||
record.public.web_public_url = None;
|
||||
record.public.can_uninstall = false;
|
||||
if record.public.message.is_none() {
|
||||
record.public.message = Some("预览实例已卸载".to_string());
|
||||
@@ -1090,6 +1102,11 @@ fn load_deployments(config: &Config) -> Result<HashMap<String, DeploymentRecord>
|
||||
if record.public.web_port.is_none() {
|
||||
record.public.web_port = url_port;
|
||||
}
|
||||
record.public.web_public_url = if record.public.web_url.is_some() {
|
||||
public_web_url(config.preview_web_domain.as_deref(), &record.instance_id)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if deployments
|
||||
.insert(record.instance_id.clone(), record)
|
||||
.is_some()
|
||||
@@ -1326,6 +1343,16 @@ fn map_artifact_health(value: &str) -> Option<HealthStatus> {
|
||||
}
|
||||
}
|
||||
|
||||
// 公网入口由控制面自己派生,主机名固定为「实例 ID + 配置的预览域名」,
|
||||
// 不接受 Jenkins 产物或状态文件提供的任意地址。
|
||||
fn public_web_url(domain: Option<&str>, instance_id: &str) -> Option<String> {
|
||||
let domain = domain?;
|
||||
if validate_deployment_id(instance_id).is_err() {
|
||||
return None;
|
||||
}
|
||||
Some(format!("https://{instance_id}.{domain}"))
|
||||
}
|
||||
|
||||
fn is_safe_web_url(value: &str, expected_host: &str) -> bool {
|
||||
url::Url::parse(value).is_ok_and(|url| {
|
||||
url.scheme() == "http"
|
||||
|
||||
@@ -171,6 +171,7 @@ fn test_config(jenkins_base_url: Url) -> Config {
|
||||
allowed_hosts: vec![HOST.to_string()],
|
||||
allowed_origins: vec![ORIGIN.to_string()],
|
||||
preview_web_host: "192.168.35.82".to_string(),
|
||||
preview_web_domain: Some("preview.genarrative.world".to_string()),
|
||||
secure_cookie: false,
|
||||
static_dir: None,
|
||||
state_file: std::env::temp_dir().join(format!(
|
||||
@@ -378,6 +379,10 @@ async fn deploy_and_uninstall_use_fixed_job_and_apply_owned_artifacts() {
|
||||
);
|
||||
assert_eq!(deployment.health, super::HealthStatus::Healthy);
|
||||
assert_eq!(deployment.web_port, Some(8400));
|
||||
assert_eq!(
|
||||
deployment.web_public_url.as_deref(),
|
||||
Some("https://preview-63d38d3da6bc9b06.preview.genarrative.world")
|
||||
);
|
||||
assert_eq!(
|
||||
deployment.web_url.as_deref(),
|
||||
Some("http://192.168.35.82:8400")
|
||||
@@ -436,6 +441,7 @@ async fn deploy_and_uninstall_use_fixed_job_and_apply_owned_artifacts() {
|
||||
assert_eq!(deployment.health, super::HealthStatus::Unknown);
|
||||
assert_eq!(deployment.web_port, None);
|
||||
assert_eq!(deployment.web_url, None);
|
||||
assert_eq!(deployment.web_public_url, None);
|
||||
assert!(!deployment.can_uninstall);
|
||||
|
||||
let list_request = axum::http::Request::builder()
|
||||
@@ -605,6 +611,7 @@ async fn duplicate_active_branch_is_rejected_without_second_jenkins_trigger() {
|
||||
health: super::HealthStatus::Pending,
|
||||
web_port: None,
|
||||
web_url: None,
|
||||
web_public_url: None,
|
||||
jenkins_build_url: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
@@ -652,6 +659,40 @@ async fn wait_for_status(
|
||||
Err(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_web_url_is_derived_from_instance_id_and_configured_domain() {
|
||||
assert_eq!(
|
||||
super::public_web_url(
|
||||
Some("preview.genarrative.world"),
|
||||
"preview-63d38d3da6bc9b06"
|
||||
)
|
||||
.as_deref(),
|
||||
Some("https://preview-63d38d3da6bc9b06.preview.genarrative.world")
|
||||
);
|
||||
assert_eq!(
|
||||
super::public_web_url(None, "preview-63d38d3da6bc9b06"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
super::public_web_url(Some("preview.genarrative.world"), "feature/demo"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_web_domain_config_is_strict() {
|
||||
assert!(super::config::validate_preview_web_domain("preview.genarrative.world").is_ok());
|
||||
assert!(super::config::validate_preview_web_domain("preview.genarrative.world:443").is_err());
|
||||
assert!(
|
||||
super::config::validate_preview_web_domain("https://preview.genarrative.world").is_err()
|
||||
);
|
||||
assert!(super::config::validate_preview_web_domain("Preview.Genarrative.World").is_err());
|
||||
assert!(super::config::validate_preview_web_domain("preview").is_err());
|
||||
assert!(super::config::validate_preview_web_domain("-preview.genarrative.world").is_err());
|
||||
assert!(super::config::validate_preview_web_domain("preview..genarrative.world").is_err());
|
||||
assert!(super::config::validate_preview_web_domain("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_commit_and_web_url_validation_are_strict() {
|
||||
assert!(super::validate_branch("feature/preview-ui").is_ok());
|
||||
@@ -758,6 +799,7 @@ async fn expired_terminal_records_are_pruned_but_uninstallable_failures_are_reta
|
||||
health: super::HealthStatus::Unknown,
|
||||
web_port: None,
|
||||
web_url: None,
|
||||
web_public_url: None,
|
||||
jenkins_build_url: None,
|
||||
created_at: old,
|
||||
updated_at: old,
|
||||
@@ -781,6 +823,7 @@ async fn expired_terminal_records_are_pruned_but_uninstallable_failures_are_reta
|
||||
health: super::HealthStatus::Unknown,
|
||||
web_port: Some(8401),
|
||||
web_url: Some("http://192.168.35.82:8401".to_string()),
|
||||
web_public_url: None,
|
||||
jenkins_build_url: None,
|
||||
created_at: old,
|
||||
updated_at: old,
|
||||
|
||||
Reference in New Issue
Block a user