同步远端分支并保留序列帧去背景修复
This commit is contained in:
@@ -86,6 +86,7 @@ test('shows health and web url, then confirms uninstall', async () => {
|
||||
resolvedCommit: '1234567890abcdef',
|
||||
status: 'running',
|
||||
health: 'healthy',
|
||||
webPort: 8400,
|
||||
webUrl: 'http://192.168.35.82:8400',
|
||||
createdAt: 1_787_270_400,
|
||||
updatedAt: 1_787_270_460,
|
||||
@@ -94,6 +95,7 @@ test('shows health and web url, then confirms uninstall', async () => {
|
||||
render(<PreviewDeployerApp />);
|
||||
|
||||
expect(await screen.findByText('健康')).toBeTruthy();
|
||||
expect(screen.getByText('端口 8400')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('link', { name: /打开 Web/u }).getAttribute('href'),
|
||||
).toBe('http://192.168.35.82:8400');
|
||||
|
||||
@@ -530,6 +530,9 @@ function DeploymentCard({
|
||||
<span className="health-dot" />
|
||||
{HEALTH_LABELS[deployment.health]}
|
||||
</span>
|
||||
{deployment.webPort ? (
|
||||
<span className="badge port-badge">端口 {deployment.webPort}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -381,6 +381,11 @@ a {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.port-badge {
|
||||
color: #475569;
|
||||
background: #f1f5f9;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
.badge-running {
|
||||
color: #14734a;
|
||||
background: #e9f9f0;
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface PreviewDeployment {
|
||||
resolvedCommit?: string | null;
|
||||
status: DeploymentStatus;
|
||||
health: DeploymentHealth;
|
||||
webPort?: number | null;
|
||||
webUrl?: string | null;
|
||||
jenkinsBuildUrl?: string | null;
|
||||
createdAt: string | number;
|
||||
|
||||
@@ -43,6 +43,10 @@ Web 端口池固定为 `8400..8499`:
|
||||
|
||||
SpacetimeDB 与 OTLP 不映射宿主端口;Jenkins 通过受控 Compose 网络中的 SpacetimeDB 容器地址完成模块发布,运行服务之间继续使用 Compose DNS。页面只展示 Web 内网地址 `http://<预览宿主>:<webPort>`。
|
||||
|
||||
SpacetimeDB 2.7 CLI 发布到受控 Compose 网络地址时固定使用 `--yes=remote,migrate,break-clients`,避免 Jenkins 等待非本地目标交互确认;该预览路径不传 `--delete-data`。Jenkins 同时固定 `GENARRATIVE_PREVIEW_WEB_HOST=192.168.35.82`,不得用默认路由自动探测结果生成页面链接,以免 VPN 或容器网卡地址泄漏到同事可见 URL。
|
||||
|
||||
预览 Compose override 将 SpacetimeDB 内存上限设为 `2g`。基础 loadtest Compose 的 `896m` 是压测采样口径,当前完整模块首次发布和实例化会超过该上限;预览环境若沿用该值,容器会被 cgroup OOM 杀死并使模块上传中断。该覆盖只作用于分支预览实例,不修改生产或压测基线。
|
||||
|
||||
## Jenkins 参数与产物
|
||||
|
||||
固定 Job:`shared/Genarrative-Preview-Deployer`。
|
||||
@@ -88,6 +92,12 @@ SpacetimeDB 与 OTLP 不映射宿主端口;Jenkins 通过受控 Compose 网络
|
||||
|
||||
页面状态统一为 `queued / building / deploying / running / uninstalling / stopped / failed / cancelled`,健康状态统一为 `pending / healthy / unhealthy / unknown`。
|
||||
|
||||
发布记录卡片直接展示后端校验后的 `webPort`。卸载成功的 `stopped` 记录仍保留在服务端持久状态中用于审计和所有权校验,但列表 API 不再返回,页面刷新后立即从发布记录中消失。
|
||||
|
||||
Jenkins 在构建完成、归档 artifact 和更新 REST 状态之间可能短暂返回不可解析的状态正文。控制服务对队列、构建状态和 artifact 查询执行有限重试;单次瞬态响应不得把已经成功并健康的部署永久写成 `failed`。
|
||||
|
||||
控制服务查询 Jenkins 队列与构建状态时必须使用 `tree` 参数限制到所需字段,避免完整 `api/json` 的大体积深层对象触发 JSON 递归深度限制。预览 Compose 中的外部生成 worker 使用 `restart: on-failure`;它若早于 API 完成模型定价运行时身份初始化而启动失败,应由 Docker 自动重启并在身份就绪后稳定运行。
|
||||
|
||||
## 安全边界
|
||||
|
||||
- 服务端缺少控制面访问口令或 Jenkins service account 凭据时必须拒绝启动,不允许退化成匿名写接口。
|
||||
|
||||
@@ -14,6 +14,7 @@ pipeline {
|
||||
GIT_REMOTE_URL = 'ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git'
|
||||
GIT_REMOTE_CREDENTIAL_ID = 'genarrative-local-gitea-ssh'
|
||||
GENARRATIVE_PREVIEW_STATE_ROOT = '/data/jenkins/preview-deployments'
|
||||
GENARRATIVE_PREVIEW_WEB_HOST = '192.168.35.82'
|
||||
}
|
||||
|
||||
parameters {
|
||||
@@ -93,13 +94,13 @@ pipeline {
|
||||
withCredentials([sshUserPrivateKey(credentialsId: env.GIT_REMOTE_CREDENTIAL_ID, keyFileVariable: 'GENARRATIVE_GIT_SSH_KEY')]) {
|
||||
sh '''
|
||||
set -euo pipefail
|
||||
SOURCE_BRANCH="${SOURCE_BRANCH}" \
|
||||
COMMIT_HASH="${COMMIT_HASH}" \
|
||||
GIT_REMOTE_URL="${GIT_REMOTE_URL}" \
|
||||
SOURCE_BRANCH="${SOURCE_BRANCH:-}" \
|
||||
COMMIT_HASH="${COMMIT_HASH:-}" \
|
||||
GIT_REMOTE_URL="${GIT_REMOTE_URL:-}" \
|
||||
SOURCE_COMMIT_FILE=".jenkins-source-commit" \
|
||||
GENARRATIVE_JENKINS_REUSE_EXISTING_CHECKOUT="true" \
|
||||
GIT_SSH_COMMAND="ssh -i ${GENARRATIVE_GIT_SSH_KEY} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" \
|
||||
"${WORKSPACE}/scripts/jenkins-checkout-source.sh"
|
||||
bash "${WORKSPACE}/scripts/jenkins-checkout-source.sh"
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,21 @@ assertIncludes(
|
||||
'disableConcurrentBuilds()',
|
||||
'Jenkins Job 必须禁止并发构建。',
|
||||
);
|
||||
assertIncludes(
|
||||
jenkinsfile,
|
||||
'COMMIT_HASH="${COMMIT_HASH:-}"',
|
||||
'Jenkins Checkout 阶段必须允许 COMMIT_HASH 未导出或为空。',
|
||||
);
|
||||
assertIncludes(
|
||||
jenkinsfile,
|
||||
'bash "${WORKSPACE}/scripts/jenkins-checkout-source.sh"',
|
||||
'Jenkins Checkout 阶段必须显式使用 bash 执行源码检出脚本。',
|
||||
);
|
||||
assertExcludes(
|
||||
jenkinsfile,
|
||||
'COMMIT_HASH="${COMMIT_HASH}"',
|
||||
'Jenkins Checkout 阶段不得在 set -u 下直接展开未导出的 COMMIT_HASH。',
|
||||
);
|
||||
assertIncludes(deployer, 'flock -x 9', '部署脚本必须使用跨进程独占锁。');
|
||||
assertIncludes(
|
||||
deployer,
|
||||
@@ -91,6 +106,21 @@ assertIncludes(
|
||||
'ports: !reset []',
|
||||
'预览 compose override 必须取消 SpacetimeDB 和 OTLP 宿主端口映射。',
|
||||
);
|
||||
assertIncludes(
|
||||
deployer,
|
||||
'spacetimedb:\n mem_limit: 2g\n ports: !reset []',
|
||||
'预览 SpacetimeDB 必须覆盖压测基线内存限制,避免模块首次加载时 OOM。',
|
||||
);
|
||||
assertIncludes(
|
||||
deployer,
|
||||
'external-generation-worker:\n build:',
|
||||
'预览 compose override 必须配置外部生成 worker。',
|
||||
);
|
||||
assertIncludes(
|
||||
deployer,
|
||||
'restart: on-failure',
|
||||
'预览外部生成 worker 必须在运行时身份初始化竞态后自动重启。',
|
||||
);
|
||||
assertIncludes(
|
||||
deployer,
|
||||
'^preview-[0-9a-f]{16}$',
|
||||
@@ -103,14 +133,19 @@ assertIncludes(
|
||||
);
|
||||
assertIncludes(
|
||||
deployer,
|
||||
'--yes=migrate,break-clients',
|
||||
'SpacetimeDB 发布不得默认删除实例数据。',
|
||||
'--yes=remote,migrate,break-clients',
|
||||
'SpacetimeDB 预览发布必须显式确认受控容器网络目标,且不得默认删除实例数据。',
|
||||
);
|
||||
assertExcludes(
|
||||
deployer,
|
||||
'--delete-data',
|
||||
'预览部署不得使用 SpacetimeDB 删除数据参数。',
|
||||
);
|
||||
assertIncludes(
|
||||
jenkinsfile,
|
||||
"GENARRATIVE_PREVIEW_WEB_HOST = '192.168.35.82'",
|
||||
'Jenkins 必须固定输出可供同事访问的内网 Web 主机地址。',
|
||||
);
|
||||
assertIncludes(webConfig, "?? '/build/'", 'SPA 默认 base 必须固定为 /build/。');
|
||||
assertIncludes(
|
||||
systemd,
|
||||
|
||||
@@ -296,11 +296,13 @@ services:
|
||||
build:
|
||||
context: ${GENARRATIVE_PREVIEW_SOURCE_DIR}
|
||||
dockerfile: ${GENARRATIVE_PREVIEW_CONTROLLER_ROOT}/deploy/container/api-server.Dockerfile
|
||||
restart: on-failure
|
||||
nginx:
|
||||
build:
|
||||
context: ${GENARRATIVE_PREVIEW_SOURCE_DIR}
|
||||
dockerfile: ${GENARRATIVE_PREVIEW_CONTROLLER_ROOT}/deploy/container/api-server.Dockerfile
|
||||
spacetimedb:
|
||||
mem_limit: 2g
|
||||
ports: !reset []
|
||||
otelcol:
|
||||
ports: !reset []
|
||||
@@ -416,7 +418,7 @@ deploy() {
|
||||
publish genarrative-loadtest \
|
||||
--server "${SPACETIME_PUBLISH_URL}" \
|
||||
--module-path server-rs/crates/spacetime-module \
|
||||
--yes=migrate,break-clients \
|
||||
--yes=remote,migrate,break-clients \
|
||||
--build-options="--debug" \
|
||||
--no-config
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::{future::Future, time::Duration};
|
||||
|
||||
use reqwest::{Client, StatusCode, header};
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, de::DeserializeOwned};
|
||||
use url::Url;
|
||||
|
||||
use crate::{Config, DeploymentStatus, HealthStatus};
|
||||
@@ -52,14 +52,13 @@ pub struct PreviewResult {
|
||||
#[serde(alias = "id")]
|
||||
pub deployment_id: Option<String>,
|
||||
pub project_name: Option<String>,
|
||||
#[serde(alias = "sourceBranch")]
|
||||
pub branch: Option<String>,
|
||||
#[serde(alias = "sourceCommit")]
|
||||
pub resolved_commit: Option<String>,
|
||||
pub status: Option<DeploymentStatus>,
|
||||
pub health: Option<HealthStatus>,
|
||||
pub phase: Option<String>,
|
||||
pub health_status: Option<String>,
|
||||
pub web_port: Option<u16>,
|
||||
pub web_url: Option<String>,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
@@ -205,11 +204,14 @@ impl JenkinsClient {
|
||||
Fut: Future<Output = ()>,
|
||||
{
|
||||
let build_url = loop {
|
||||
let item_url = reference
|
||||
let mut item_url = reference
|
||||
.queue_url
|
||||
.join("api/json")
|
||||
.map_err(|error| error.to_string())?;
|
||||
let item: QueueItem = self.get_json(item_url).await?;
|
||||
item_url
|
||||
.query_pairs_mut()
|
||||
.append_pair("tree", "cancelled,executable[url]");
|
||||
let item: QueueItem = self.get_json_with_retry(item_url).await?;
|
||||
if item.cancelled.unwrap_or(false) {
|
||||
return Ok(JenkinsOutcome {
|
||||
success: false,
|
||||
@@ -226,10 +228,13 @@ impl JenkinsClient {
|
||||
|
||||
on_build(build_url.as_str()).await;
|
||||
let successful = loop {
|
||||
let state_url = build_url
|
||||
let mut state_url = build_url
|
||||
.join("api/json")
|
||||
.map_err(|error| error.to_string())?;
|
||||
let state: BuildState = self.get_json(state_url).await?;
|
||||
state_url
|
||||
.query_pairs_mut()
|
||||
.append_pair("tree", "building,result");
|
||||
let state: BuildState = self.get_json_with_retry(state_url).await?;
|
||||
if state.building {
|
||||
tokio::time::sleep(self.poll_interval).await;
|
||||
continue;
|
||||
@@ -247,7 +252,7 @@ impl JenkinsClient {
|
||||
let artifact_url = build_url
|
||||
.join("artifact/preview-result.json")
|
||||
.map_err(|error| error.to_string())?;
|
||||
let result = self.get_json(artifact_url).await?;
|
||||
let result = self.get_json_with_retry(artifact_url).await?;
|
||||
Ok(JenkinsOutcome {
|
||||
success: true,
|
||||
cancelled: false,
|
||||
@@ -267,18 +272,50 @@ impl JenkinsClient {
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("Jenkins 状态返回 HTTP {}", response.status()));
|
||||
}
|
||||
response
|
||||
.json()
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|_| "Jenkins 状态响应格式无效".to_string())
|
||||
.map_err(|error| format!("Jenkins 状态响应正文读取失败: {error}"))?;
|
||||
serde_json::from_slice(&bytes).map_err(|error| format!("Jenkins 状态响应格式无效: {error}"))
|
||||
}
|
||||
|
||||
async fn get_json_with_retry<T: DeserializeOwned>(&self, url: Url) -> Result<T, String> {
|
||||
const MAX_ATTEMPTS: usize = 10;
|
||||
let mut last_error = None;
|
||||
for attempt in 1..=MAX_ATTEMPTS {
|
||||
match self.get_json(url.clone()).await {
|
||||
Ok(value) => return Ok(value),
|
||||
Err(error) => last_error = Some(error),
|
||||
}
|
||||
if attempt < MAX_ATTEMPTS {
|
||||
tokio::time::sleep(self.poll_interval).await;
|
||||
}
|
||||
}
|
||||
Err(last_error.unwrap_or_else(|| "Jenkins 状态请求失败".to_string()))
|
||||
}
|
||||
|
||||
fn resolve_trusted_url(&self, value: &str) -> Result<Url, String> {
|
||||
let url = Url::parse(value)
|
||||
let returned = Url::parse(value)
|
||||
.or_else(|_| self.job_url.join(value))
|
||||
.map_err(|_| "Jenkins 返回了无效 URL".to_string())?;
|
||||
self.ensure_same_origin(&url)?;
|
||||
Ok(url)
|
||||
if !returned.username().is_empty()
|
||||
|| returned.password().is_some()
|
||||
|| !returned.path().starts_with(self.root_url.path())
|
||||
{
|
||||
return Err("Jenkins 返回了非受信源 URL".to_string());
|
||||
}
|
||||
let relative_path = returned
|
||||
.path()
|
||||
.strip_prefix(self.root_url.path())
|
||||
.ok_or_else(|| "Jenkins 返回了非受信路径".to_string())?;
|
||||
let mut trusted = self
|
||||
.root_url
|
||||
.join(relative_path)
|
||||
.map_err(|_| "Jenkins 返回了无效 URL".to_string())?;
|
||||
trusted.set_query(returned.query());
|
||||
trusted.set_fragment(None);
|
||||
self.ensure_same_origin(&trusted)?;
|
||||
Ok(trusted)
|
||||
}
|
||||
|
||||
fn ensure_same_origin(&self, url: &Url) -> Result<(), String> {
|
||||
|
||||
@@ -24,6 +24,7 @@ use tower_http::{
|
||||
trace::TraceLayer,
|
||||
};
|
||||
use tracing::{error, warn};
|
||||
use url::Url;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub use config::Config;
|
||||
@@ -86,6 +87,8 @@ pub struct Deployment {
|
||||
pub resolved_commit: Option<String>,
|
||||
pub status: DeploymentStatus,
|
||||
pub health: HealthStatus,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub web_port: Option<u16>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub web_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -385,6 +388,7 @@ async fn list_deployments(
|
||||
.read()
|
||||
.await
|
||||
.values()
|
||||
.filter(|record| record.public.status != DeploymentStatus::Stopped)
|
||||
.map(|record| record.public.clone())
|
||||
.collect();
|
||||
deployments.sort_by(|left, right| right.created_at.cmp(&left.created_at));
|
||||
@@ -494,6 +498,7 @@ async fn create_deployment(
|
||||
resolved_commit: None,
|
||||
status: DeploymentStatus::Queued,
|
||||
health: HealthStatus::Pending,
|
||||
web_port: None,
|
||||
web_url: None,
|
||||
jenkins_build_url: None,
|
||||
created_at: now,
|
||||
@@ -770,8 +775,23 @@ async fn apply_outcome(state: &AppState, id: &str, outcome: JenkinsOutcome) {
|
||||
record.public.status = DeploymentStatus::Failed;
|
||||
record.public.health = HealthStatus::Unknown;
|
||||
record.public.message = Some("Jenkins 返回了无效 Web 地址".to_string());
|
||||
} else if result
|
||||
.web_port
|
||||
.is_some_and(|value| !(8400..=8499).contains(&value))
|
||||
|| result
|
||||
.web_url
|
||||
.as_deref()
|
||||
.zip(result.web_port)
|
||||
.is_some_and(|(url, port)| {
|
||||
Url::parse(url).ok().and_then(|url| url.port()) != Some(port)
|
||||
})
|
||||
{
|
||||
record.public.status = DeploymentStatus::Failed;
|
||||
record.public.health = HealthStatus::Unknown;
|
||||
record.public.message = Some("Jenkins 返回了无效 Web 端口".to_string());
|
||||
} else if record.operation == Operation::Deploy
|
||||
&& (result.resolved_commit.is_none()
|
||||
|| result.web_port.is_none()
|
||||
|| result.web_url.is_none()
|
||||
|| result.phase.as_deref() != Some("RUNNING")
|
||||
|| result.health_status.as_deref() != Some("HEALTHY"))
|
||||
@@ -794,6 +814,9 @@ async fn apply_outcome(state: &AppState, id: &str, outcome: JenkinsOutcome) {
|
||||
if let Some(value) = result.web_url {
|
||||
record.public.web_url = Some(value);
|
||||
}
|
||||
if let Some(value) = result.web_port {
|
||||
record.public.web_port = Some(value);
|
||||
}
|
||||
if let Some(value) = result.health {
|
||||
record.public.health = value;
|
||||
}
|
||||
@@ -838,6 +861,7 @@ async fn apply_outcome(state: &AppState, id: &str, outcome: JenkinsOutcome) {
|
||||
Operation::Uninstall => {
|
||||
record.public.status = DeploymentStatus::Stopped;
|
||||
record.public.health = HealthStatus::Unknown;
|
||||
record.public.web_port = None;
|
||||
record.public.web_url = None;
|
||||
record.public.can_uninstall = false;
|
||||
if record.public.message.is_none() {
|
||||
@@ -877,7 +901,7 @@ fn load_deployments(config: &Config) -> Result<HashMap<String, DeploymentRecord>
|
||||
return Err("预览部署状态文件 schemaVersion 不受支持".to_string());
|
||||
}
|
||||
let mut deployments = HashMap::new();
|
||||
for record in persisted.deployments {
|
||||
for mut record in persisted.deployments {
|
||||
validate_deployment_id(&record.public.id)
|
||||
.map_err(|_| "状态文件包含无效部署 ID".to_string())?;
|
||||
let branch = validate_branch(&record.public.branch)
|
||||
@@ -896,6 +920,27 @@ fn load_deployments(config: &Config) -> Result<HashMap<String, DeploymentRecord>
|
||||
{
|
||||
return Err("状态文件包含无效 Web 地址".to_string());
|
||||
}
|
||||
let url_port = record
|
||||
.public
|
||||
.web_url
|
||||
.as_deref()
|
||||
.and_then(|value| Url::parse(value).ok())
|
||||
.and_then(|url| url.port());
|
||||
if record
|
||||
.public
|
||||
.web_port
|
||||
.is_some_and(|value| !(8400..=8499).contains(&value))
|
||||
|| record
|
||||
.public
|
||||
.web_port
|
||||
.zip(url_port)
|
||||
.is_some_and(|(saved, parsed)| saved != parsed)
|
||||
{
|
||||
return Err("状态文件包含无效 Web 端口".to_string());
|
||||
}
|
||||
if record.public.web_port.is_none() {
|
||||
record.public.web_port = url_port;
|
||||
}
|
||||
if deployments
|
||||
.insert(record.public.id.clone(), record)
|
||||
.is_some()
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use std::{
|
||||
sync::{Arc, Mutex},
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
@@ -25,6 +28,7 @@ const ORIGIN: &str = "http://preview.internal:8080";
|
||||
#[derive(Clone, Default)]
|
||||
struct MockJenkinsState {
|
||||
requests: Arc<Mutex<Vec<RecordedRequest>>>,
|
||||
invalid_build_responses: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -95,12 +99,27 @@ async fn mock_trigger(
|
||||
|
||||
async fn mock_queue(axum::extract::Path(id): axum::extract::Path<u64>) -> Json<Value> {
|
||||
Json(
|
||||
json!({"cancelled": false, "executable": {"url": format!("/jenkins/job/shared/job/Genarrative-Preview-Deployer/{id}/")}}),
|
||||
json!({"cancelled": false, "executable": {"url": format!("http://192.168.35.82:8080/jenkins/job/shared/job/Genarrative-Preview-Deployer/{id}/")}}),
|
||||
)
|
||||
}
|
||||
|
||||
async fn mock_build() -> Json<Value> {
|
||||
Json(json!({"building": false, "result": "SUCCESS"}))
|
||||
async fn mock_build(
|
||||
State(state): State<MockJenkinsState>,
|
||||
uri: axum::http::Uri,
|
||||
) -> impl IntoResponse {
|
||||
if uri.query() != Some("tree=building%2Cresult") {
|
||||
return (StatusCode::BAD_REQUEST, "missing bounded tree query").into_response();
|
||||
}
|
||||
if state
|
||||
.invalid_build_responses
|
||||
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| {
|
||||
remaining.checked_sub(1)
|
||||
})
|
||||
.is_ok()
|
||||
{
|
||||
return (StatusCode::OK, "Jenkins is finalizing the build").into_response();
|
||||
}
|
||||
Json(json!({"building": false, "result": "SUCCESS"})).into_response()
|
||||
}
|
||||
|
||||
async fn mock_artifact(axum::extract::Path(build): axum::extract::Path<u64>) -> Json<Value> {
|
||||
@@ -111,9 +130,10 @@ async fn mock_artifact(axum::extract::Path(build): axum::extract::Path<u64>) ->
|
||||
"deploymentId": "preview-63d38d3da6bc9b06",
|
||||
"projectName": "genarrative-preview-63d38d3da6bc9b06",
|
||||
"branch": "feature/preview-ui",
|
||||
"sourceCommit": "0123456789abcdef0123456789abcdef01234567",
|
||||
"resolvedCommit": "0123456789abcdef0123456789abcdef01234567",
|
||||
"phase": "RUNNING",
|
||||
"healthStatus": "HEALTHY",
|
||||
"webPort": 8400,
|
||||
"webUrl": "http://192.168.35.82:8400",
|
||||
"message": "预览实例已发布"
|
||||
}))
|
||||
@@ -346,6 +366,7 @@ async fn deploy_and_uninstall_use_fixed_job_and_apply_owned_artifacts() {
|
||||
.clone();
|
||||
assert_eq!(deployment.status, super::DeploymentStatus::Running);
|
||||
assert_eq!(deployment.health, super::HealthStatus::Healthy);
|
||||
assert_eq!(deployment.web_port, Some(8400));
|
||||
assert_eq!(
|
||||
deployment.web_url.as_deref(),
|
||||
Some("http://192.168.35.82:8400")
|
||||
@@ -402,8 +423,27 @@ async fn deploy_and_uninstall_use_fixed_job_and_apply_owned_artifacts() {
|
||||
.clone();
|
||||
assert_eq!(deployment.status, super::DeploymentStatus::Stopped);
|
||||
assert_eq!(deployment.health, super::HealthStatus::Unknown);
|
||||
assert_eq!(deployment.web_port, None);
|
||||
assert_eq!(deployment.web_url, None);
|
||||
assert!(!deployment.can_uninstall);
|
||||
|
||||
let list_request = axum::http::Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/preview-deployer/deployments")
|
||||
.header(header::HOST, HOST)
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let list_response = app.clone().oneshot(list_request).await.unwrap();
|
||||
assert_eq!(list_response.status(), StatusCode::OK);
|
||||
let list_body = list_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap()
|
||||
.to_bytes();
|
||||
let list: Value = serde_json::from_slice(&list_body).unwrap();
|
||||
assert_eq!(list["deployments"], json!([]));
|
||||
let state_file = state.config.state_file.clone();
|
||||
let mut recovered_config = test_config(state.config.jenkins_root_url.clone());
|
||||
recovered_config.state_file = state_file.clone();
|
||||
@@ -424,6 +464,44 @@ async fn deploy_and_uninstall_use_fixed_job_and_apply_owned_artifacts() {
|
||||
std::fs::remove_file(state_file).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transient_invalid_build_status_is_retried_before_reading_artifact() {
|
||||
let (jenkins_url, mock) = start_mock_jenkins().await;
|
||||
mock.invalid_build_responses.store(1, Ordering::SeqCst);
|
||||
let state = AppState::new(test_config(jenkins_url)).unwrap();
|
||||
let app = build_router(state.clone());
|
||||
let cookie = login_cookie(&app).await;
|
||||
let id = super::derive_deployment_id("feature/preview-ui");
|
||||
let deploy_request = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/preview-deployer/deployments")
|
||||
.header(header::HOST, HOST)
|
||||
.header(header::ORIGIN, ORIGIN)
|
||||
.header(header::COOKIE, &cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(r#"{"branch":"feature/preview-ui"}"#))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
app.oneshot(deploy_request).await.unwrap().status(),
|
||||
StatusCode::ACCEPTED
|
||||
);
|
||||
wait_for_status(&state, &id, super::DeploymentStatus::Running)
|
||||
.await
|
||||
.expect("transient invalid Jenkins response is retried");
|
||||
assert_eq!(
|
||||
state
|
||||
.deployments
|
||||
.read()
|
||||
.await
|
||||
.get(&id)
|
||||
.unwrap()
|
||||
.public
|
||||
.health,
|
||||
super::HealthStatus::Healthy
|
||||
);
|
||||
std::fs::remove_file(&state.config.state_file).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn duplicate_active_branch_is_rejected_without_second_jenkins_trigger() {
|
||||
let (jenkins_url, mock) = start_mock_jenkins().await;
|
||||
@@ -442,6 +520,7 @@ async fn duplicate_active_branch_is_rejected_without_second_jenkins_trigger() {
|
||||
resolved_commit: None,
|
||||
status: super::DeploymentStatus::Building,
|
||||
health: super::HealthStatus::Pending,
|
||||
web_port: None,
|
||||
web_url: None,
|
||||
jenkins_build_url: None,
|
||||
created_at: now,
|
||||
@@ -519,3 +598,43 @@ fn branch_commit_and_web_url_validation_are_strict() {
|
||||
"192.168.35.82"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_running_state_recovers_web_port_from_validated_url() {
|
||||
let config = test_config(Url::parse("http://127.0.0.1:18080/jenkins/").unwrap());
|
||||
let id = super::derive_deployment_id("feature/legacy-running");
|
||||
std::fs::write(
|
||||
&config.state_file,
|
||||
serde_json::to_vec(&json!({
|
||||
"schemaVersion": 1,
|
||||
"deployments": [{
|
||||
"public": {
|
||||
"id": id,
|
||||
"branch": "feature/legacy-running",
|
||||
"status": "running",
|
||||
"health": "healthy",
|
||||
"webUrl": "http://192.168.35.82:8407",
|
||||
"createdAt": 1,
|
||||
"updatedAt": 2,
|
||||
"canUninstall": true
|
||||
},
|
||||
"operation": "deploy"
|
||||
}]
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let state = AppState::new(config).unwrap();
|
||||
assert_eq!(
|
||||
state
|
||||
.deployments
|
||||
.blocking_read()
|
||||
.get(&id)
|
||||
.unwrap()
|
||||
.public
|
||||
.web_port,
|
||||
Some(8407)
|
||||
);
|
||||
std::fs::remove_file(&state.config.state_file).unwrap();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user