预览控制面支持展示公网预览地址
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m12s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 7m21s
Project CI / AI game creator shell Rust shard 4/4 (push) Failing after 7m21s
Project CI / AI game creator shell Rust shard 1/4 (push) Failing after 7m26s
Project CI / Native shell tests (push) Failing after 1m50s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m15s
Project CI / AI game creator shell Rust crates (push) Successful in 2m19s
Project CI / Backend tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled

- preview-deployer-server 新增可选配置 GENARRATIVE_PREVIEW_DEPLOYER_WEB_DOMAIN,并按「实例 ID + 预览域名」派生公开字段 webPublicUrl,卸载时与 webUrl/webPort 一起清空、加载状态文件时重新派生覆盖
- preview-deployer-web 在存在公网地址时把「打开公网预览」作为主入口,内网地址降级为次级链接,未配置时行为不变
- deploy/env/preview-deployer.env.example 补充 WEB_DOMAIN 键与留空语义说明
- 补充 cargo 与 vitest 用例覆盖公网地址派生、域名配置校验与页面展示
This commit is contained in:
2026-09-17 20:24:11 +08:00
parent fce546403f
commit f5b4293e80
7 changed files with 143 additions and 0 deletions
@@ -59,6 +59,32 @@ test('requires an access token before showing deployments', async () => {
expect(await screen.findByText('构建并发布一个分支')).toBeTruthy();
});
test('prefers the public preview domain when the control plane exposes one', async () => {
vi.mocked(api.listDeployments).mockResolvedValue([
{
id: '77',
branch: 'feature/public-preview',
resolvedCommit: '1234567890abcdef',
status: 'running',
health: 'healthy',
webPort: 8400,
webUrl: 'http://192.168.35.82:8400',
webPublicUrl:
'https://preview-63d38d3da6bc9b06.preview.genarrative.world',
createdAt: 1_787_270_400,
updatedAt: 1_787_270_460,
},
]);
render(<PreviewDeployerApp />);
const publicLink = await screen.findByRole('link', {
name: //u,
});
expect(publicLink.getAttribute('href')).toBe(
'https://preview-63d38d3da6bc9b06.preview.genarrative.world',
);
});
test('submits a branch with an optional commit hash', async () => {
const user = userEvent.setup();
render(<PreviewDeployerApp />);
@@ -797,6 +797,16 @@ function DeploymentCard({
</div>
<div className="card-actions">
{deployment.webUrl && deployment.webPublicUrl ? (
<a
className={deployment.webPublicUrl ? 'text-link' : 'primary-link'}
href={deployment.webPublicUrl}
target="_blank"
rel="noreferrer"
>
<ExternalLink size={15} />
</a>
) : null}
{deployment.webUrl ? (
<a
className="primary-link"
+1
View File
@@ -19,6 +19,7 @@ export interface PreviewDeployment {
health: DeploymentHealth;
webPort?: number | null;
webUrl?: string | null;
webPublicUrl?: string | null;
jenkinsBuildUrl?: string | null;
createdAt: string | number;
updatedAt: string | number;
+2
View File
@@ -13,6 +13,8 @@ GENARRATIVE_PREVIEW_DEPLOYER_ACCESS_TOKEN=<至少24字符的控制面访问口
GENARRATIVE_PREVIEW_DEPLOYER_ALLOWED_HOSTS=192.168.35.82
GENARRATIVE_PREVIEW_DEPLOYER_ALLOWED_ORIGINS=http://192.168.35.82
GENARRATIVE_PREVIEW_DEPLOYER_WEB_HOST=192.168.35.82
# 可选:公网预览域名后缀。配置后页面会展示 https://<实例ID>.<后缀>,留空则只展示内网地址。
GENARRATIVE_PREVIEW_DEPLOYER_WEB_DOMAIN=preview.genarrative.world
GENARRATIVE_PREVIEW_DEPLOYER_STATE_FILE=/var/lib/genarrative/preview-deployer/state.json
GENARRATIVE_PREVIEW_DEPLOYER_STATIC_DIR=/opt/genarrative/preview-deployer/web
GENARRATIVE_PREVIEW_DEPLOYER_SECURE_COOKIE=false
@@ -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,