预览控制面支持展示公网预览地址
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
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:
@@ -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"
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user