fix(update): reuse the served asset directory (#111)

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-09-16 18:05:14 +08:00
committed by GitHub
co-authored by Zyy Codex
parent c9318c54cd
commit 4fb10f33f1
12 changed files with 269 additions and 40 deletions
+5 -4
View File
@@ -28,7 +28,11 @@ impl LoadableConfig for GatewayConfig {
if self.schema_version != CONFIG_SCHEMA_VERSION {
return Err(ConfigError::InvalidField("schema_version"));
}
self.host.resolve_and_validate(source_dir)?;
self.api
.resolve_and_validate(source_dir)
.map_err(|_| ConfigError::InvalidField("api"))?;
self.host
.resolve_and_validate(source_dir, &self.api.asset_directory)?;
let runtime_data_dir = self.host.runtime_data_dir().to_path_buf();
self.store
.resolve_and_validate(&runtime_data_dir)
@@ -39,9 +43,6 @@ impl LoadableConfig for GatewayConfig {
self.client
.resolve_and_validate(source_dir)
.map_err(|_| ConfigError::InvalidField("client"))?;
self.api
.resolve_and_validate(source_dir)
.map_err(|_| ConfigError::InvalidField("api"))?;
self.openai
.resolve_and_validate(&runtime_data_dir)
.map_err(|_| ConfigError::InvalidField("openai"))?;
+44
View File
@@ -168,6 +168,50 @@ fn config_loader_should_resolve_paths_relative_to_config_file() {
assert!(debug.contains("frontend/dist"));
}
#[test]
fn config_loader_should_share_resolved_assets_with_system_update() {
const CHILD_ENV: &str = "CPR_TEST_UPDATE_ASSETS_CHILD";
let Ok(case) = std::env::var(CHILD_ENV) else {
// 使用子进程覆盖无环境变量、Docker 路径和相对路径,避免污染并行测试。
for (case, web_dist) in [
("binary", None),
("docker", Some("/app/web/dist")),
("relative", Some("../custom/dist")),
] {
let mut child = Command::new(std::env::current_exe().expect("test executable"));
child
.args([
"--exact",
"bootstrap::config_loader_should_share_resolved_assets_with_system_update",
])
.env(CHILD_ENV, case)
.env_remove("CPR_WEB_DIST_DIR");
if let Some(web_dist) = web_dist {
child.env("CPR_WEB_DIST_DIR", web_dist);
}
let output = child.output().expect("isolated configuration test");
assert!(
output.status.success(),
"{case}: {}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
return;
};
let mut document = valid_config_document();
document["api"]["asset_directory"] = serde_json::json!("../web/dist");
let (config, directory) = parse_config(&document.to_string()).expect("binary configuration");
let assets = match case.as_str() {
"docker" => std::path::PathBuf::from("/app/web/dist"),
"relative" => directory.path().join("deploy/../custom/dist"),
_ => directory.path().join("deploy/../web/dist"),
};
let debug = format!("{config:?}");
assert!(debug.contains(&format!("asset_directory: {assets:?}")));
assert!(debug.contains(&format!("web_dist_dir: Some({assets:?})")));
}
#[test]
fn config_loader_should_reject_missing_runtime_data_dir() {
let config = valid_config().replace(" runtime_data_dir: '../.runtime/data'\n", "");
+10 -6
View File
@@ -62,12 +62,17 @@ impl ApiConfig {
return Err(ApiConfigError::InvalidAssetDirectory);
}
}
if self.asset_directory.as_os_str().is_empty() {
return Err(ApiConfigError::InvalidAssetDirectory);
}
self.validate()?;
if self.asset_directory.is_relative() {
self.asset_directory = source_dir.join(&self.asset_directory);
}
Ok(())
}
fn validate(&mut self) -> Result<(), ApiConfigError> {
if self.asset_directory.as_os_str().is_empty() {
return Err(ApiConfigError::InvalidAssetDirectory);
}
if self.request_timeout_seconds == Some(0) {
return Err(ApiConfigError::InvalidRequestTimeout);
}
@@ -124,9 +129,8 @@ pub fn initialize(
worker_health: Arc<dyn WorkerHealthSource>,
lifecycle: Arc<dyn ConnectionLifecycle>,
) -> Result<ApiBundle, ApiError> {
config
.resolve_and_validate(Path::new("."))
.map_err(ApiError::Config)?;
// 配置加载已解析环境变量与相对路径,初始化只校验,避免覆盖最终目录。
config.validate().map_err(ApiError::Config)?;
let request_id_header = HeaderName::from_str(&config.request_id_header)
.map_err(|_| ApiError::Config(ApiConfigError::InvalidRequestIdHeader))?;
let state = ApiState {
+52
View File
@@ -5,3 +5,55 @@ mod health;
mod key_usage;
mod openai;
mod support;
#[tokio::test]
async fn api_should_serve_resolved_assets_with_relative_environment_override() {
use axum::{body::Body, http::Request};
use std::{fs, process::Command};
use tower::ServiceExt as _;
const CHILD_ENV: &str = "CPR_TEST_ASSET_DIRECTORY";
let Ok(directory) = std::env::var(CHILD_ENV) else {
let directory = std::env::temp_dir().join(format!("cpr-assets-{}", uuid::Uuid::now_v7()));
fs::create_dir_all(directory.join("deploy")).expect("config directory");
fs::create_dir_all(directory.join("web/dist")).expect("asset directory");
fs::write(directory.join("web/dist/index.html"), "resolved-web-assets").expect("index");
let output = Command::new(std::env::current_exe().expect("test executable"))
.args([
"--exact",
"api_should_serve_resolved_assets_with_relative_environment_override",
])
.current_dir(&directory)
.env(CHILD_ENV, &directory)
.env("CPR_WEB_DIST_DIR", "../web/dist")
.output()
.expect("isolated asset directory test");
fs::remove_dir_all(directory).expect("remove fixture");
assert!(
output.status.success(),
"{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
return;
};
let mut config = gateway_api::ApiConfig {
asset_directory: "unused".into(),
cors_allowed_origins: Vec::new(),
request_timeout_seconds: None,
request_id_header: "x-request-id".to_owned(),
};
config
.resolve_and_validate(&std::path::Path::new(&directory).join("deploy"))
.expect("resolved API config");
let admin = admin::AdminTestFixture::new().await;
let response = openai::api_router_with_config(admin.services, config)
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.expect("static response");
assert_eq!(response.status(), axum::http::StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), 1024)
.await
.unwrap();
assert_eq!(body.as_ref(), b"resolved-web-assets");
}
+11 -1
View File
@@ -50,13 +50,23 @@ pub(super) async fn api_router(execution: Arc<dyn ExecutionService>) -> axum::Ro
}
pub(super) fn api_router_with_admin(admin: gateway_admin::AdminServices) -> axum::Router {
gateway_api::initialize(
api_router_with_config(
admin,
gateway_api::ApiConfig {
asset_directory: std::env::temp_dir(),
cors_allowed_origins: Vec::new(),
request_timeout_seconds: None,
request_id_header: "x-request-id".to_owned(),
},
)
}
pub(super) fn api_router_with_config(
admin: gateway_admin::AdminServices,
config: gateway_api::ApiConfig,
) -> axum::Router {
gateway_api::initialize(
config,
Arc::new(DefaultExecutionService::new(
RuntimeSnapshotHandle::new(snapshot("unused-client-route-key", "openai")),
Arc::new(UnusedExecutionStore),
+11 -3
View File
@@ -52,7 +52,12 @@ pub struct HostConfig {
}
impl HostConfig {
pub fn resolve_and_validate(&mut self, source_dir: &Path) -> Result<(), ConfigError> {
/// 解析 Host 配置;在线更新默认沿用已解析的 API 静态资源目录。
pub fn resolve_and_validate(
&mut self,
source_dir: &Path,
asset_directory: &Path,
) -> Result<(), ConfigError> {
if let Some(host) = optional_environment_value(SERVER_HOST_ENV)? {
self.listen.host = host;
}
@@ -82,8 +87,11 @@ impl HostConfig {
}
resolve_relative_path(source_dir, &mut self.runtime_data_dir);
self.logging.resolve_and_validate(source_dir)?;
self.system_update
.resolve_and_validate(source_dir, &self.runtime_data_dir)?;
self.system_update.resolve_and_validate(
source_dir,
&self.runtime_data_dir,
asset_directory,
)?;
Ok(())
}
@@ -43,7 +43,6 @@ use self::swap::{replace_release_files, rollback_release};
pub use self::release::validate_download_url;
const APP_BINARY_NAME: &str = "codex-proxy-rs";
const DEFAULT_WEB_DIST_DIR: &str = "/app/web/dist";
const DEFAULT_GITHUB_API_BASE: &str = "https://api.github.com/repos";
const DEFAULT_UPDATE_REPOSITORY: &str = "zyycn/codex-proxy-rs";
@@ -62,7 +61,8 @@ pub struct SystemUpdateConfig {
pub update_repository: Option<String>,
pub github_api_base: String,
pub executable_path: Option<PathBuf>,
pub web_dist_dir: PathBuf,
/// 未显式指定时,由组合根传入 API 实际使用的静态资源目录。
pub web_dist_dir: Option<PathBuf>,
pub update_state_file: PathBuf,
pub update_lock_file: PathBuf,
pub update_temp_dir: PathBuf,
@@ -107,9 +107,7 @@ impl Default for SystemUpdateConfig {
github_api_base: environment_value("CPR_GITHUB_API_BASE")
.unwrap_or_else(|| DEFAULT_GITHUB_API_BASE.to_owned()),
executable_path,
web_dist_dir: environment_value("CPR_WEB_DIST_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(DEFAULT_WEB_DIST_DIR)),
web_dist_dir: None,
update_state_file,
update_lock_file,
update_temp_dir,
@@ -124,8 +122,15 @@ impl SystemUpdateConfig {
&mut self,
source_dir: &Path,
runtime_data_dir: &Path,
asset_directory: &Path,
) -> Result<(), ConfigError> {
for path in [self.executable_path.as_mut(), Some(&mut self.web_dist_dir)]
let web_dist_dir = self
.web_dist_dir
.get_or_insert_with(|| asset_directory.to_path_buf());
if web_dist_dir.as_os_str().is_empty() {
return Err(ConfigError::InvalidField("host.system_update.web_dist_dir"));
}
for path in [self.executable_path.as_mut(), self.web_dist_dir.as_mut()]
.into_iter()
.flatten()
{
@@ -174,6 +179,9 @@ impl SystemUpdateConfig {
if let Err(error) = release::validate_api_base(&self.github_api_base) {
return Some(error);
}
if let Err(error) = self.web_dist_dir() {
return Some(error.to_string());
}
None
}
@@ -197,6 +205,13 @@ impl SystemUpdateConfig {
.and_then(fs::canonicalize)
.map_err(|error| internal(format!("failed to resolve executable: {error}")))
}
pub(crate) fn web_dist_dir(&self) -> Result<&Path, OperationError> {
self.web_dist_dir
.as_deref()
.filter(|path| !path.as_os_str().is_empty())
.ok_or_else(|| conflict("web assets directory is not configured"))
}
}
/// gateway-admin 消费的真实进程/文件系统操作实现。
@@ -395,7 +410,7 @@ impl ProcessSystemOperations {
.info(Some(operation_id), Some("replace"), "正在替换应用文件");
replace_release_files(
&self.config.executable_path()?,
&self.config.web_dist_dir,
self.config.web_dist_dir()?,
extracted,
)?;
self.events
@@ -81,6 +81,7 @@ pub(crate) fn replace_release_files(
}
pub(crate) fn rollback_release(config: &SystemUpdateConfig) -> Result<(), OperationError> {
let web_dist = config.web_dist_dir()?;
let executable = config.executable_path()?;
let binary_backup = backup_path_for(&executable);
if !binary_backup.exists() {
@@ -89,9 +90,9 @@ pub(crate) fn rollback_release(config: &SystemUpdateConfig) -> Result<(), Operat
swap_file(&executable, &binary_backup)
.map_err(|error| internal(format!("binary rollback failed: {error}")))?;
let web_backup = backup_path_for(&config.web_dist_dir);
let web_backup = backup_path_for(web_dist);
if web_backup.exists()
&& let Err(error) = swap_dir(&config.web_dist_dir, &web_backup)
&& let Err(error) = swap_dir(web_dist, &web_backup)
{
let mut rollback = Vec::new();
collect_rollback_error(
+71 -6
View File
@@ -21,11 +21,61 @@ fn system_update_defaults_should_use_host_build_metadata_and_official_repository
);
}
#[test]
fn host_config_should_default_update_assets_to_the_resolved_api_directory() {
let mut config = valid_config();
config.system_update.web_dist_dir = None;
let assets = std::path::Path::new("/srv/gateway/web/dist");
config
.resolve_and_validate(std::path::Path::new("/srv/gateway/deploy"), assets)
.expect("host config");
assert_eq!(config.system_update.web_dist_dir.as_deref(), Some(assets));
}
#[test]
fn host_config_should_preserve_explicit_update_asset_directory() {
let mut config = valid_config();
config.system_update.web_dist_dir = Some(PathBuf::from("../custom/dist"));
config
.resolve_and_validate(
std::path::Path::new("/srv/gateway/deploy"),
std::path::Path::new("/srv/gateway/web/dist"),
)
.expect("host config");
assert_eq!(
config.system_update.web_dist_dir,
Some(PathBuf::from("/srv/gateway/deploy/../custom/dist"))
);
}
#[test]
fn host_config_should_reject_empty_update_asset_directory() {
let mut config = valid_config();
config.system_update.web_dist_dir = Some(PathBuf::new());
assert!(matches!(
config.resolve_and_validate(
std::path::Path::new("/srv/gateway/deploy"),
std::path::Path::new("/srv/gateway/web/dist"),
),
Err(gateway_host::ConfigError::InvalidField(
"host.system_update.web_dist_dir"
))
));
}
#[test]
fn host_config_resolves_only_host_owned_relative_paths() {
let mut config = valid_config();
config
.resolve_and_validate(std::path::Path::new("/srv/gateway"))
.resolve_and_validate(
std::path::Path::new("/srv/gateway"),
std::path::Path::new("/srv/gateway/web/dist"),
)
.expect("valid host config");
assert_eq!(
@@ -38,7 +88,10 @@ fn host_config_resolves_only_host_owned_relative_paths() {
fn host_config_resolves_runtime_data_dir_relative_to_configuration() {
let mut config = valid_config();
config
.resolve_and_validate(std::path::Path::new("/srv/gateway"))
.resolve_and_validate(
std::path::Path::new("/srv/gateway"),
std::path::Path::new("/srv/gateway/web/dist"),
)
.expect("valid host config");
assert_eq!(
@@ -51,7 +104,10 @@ fn host_config_resolves_runtime_data_dir_relative_to_configuration() {
fn host_config_derives_update_paths_from_runtime_data_dir() {
let mut config = valid_config();
config
.resolve_and_validate(std::path::Path::new("/srv/gateway"))
.resolve_and_validate(
std::path::Path::new("/srv/gateway"),
std::path::Path::new("/srv/gateway/web/dist"),
)
.expect("valid host config");
assert_eq!(
@@ -75,7 +131,10 @@ fn host_config_rejects_zero_drain_window() {
assert!(
config
.resolve_and_validate(std::path::Path::new("/srv/gateway"))
.resolve_and_validate(
std::path::Path::new("/srv/gateway"),
std::path::Path::new("/srv/gateway/web/dist"),
)
.is_err()
);
}
@@ -91,7 +150,10 @@ fn logging_rejects_zero_retention_windows() {
}
assert!(
config
.resolve_and_validate(std::path::Path::new("/srv/gateway"))
.resolve_and_validate(
std::path::Path::new("/srv/gateway"),
std::path::Path::new("/srv/gateway/web/dist"),
)
.is_err()
);
}
@@ -120,7 +182,10 @@ fn dedicated_file_channels_can_be_enabled_without_application_sinks() {
config.logging.oauth_recovery = oauth_recovery;
config.logging.request_dump = !oauth_recovery;
config
.resolve_and_validate(std::path::Path::new("/srv/gateway"))
.resolve_and_validate(
std::path::Path::new("/srv/gateway"),
std::path::Path::new("/srv/gateway/web/dist"),
)
.unwrap();
}
}
@@ -53,7 +53,10 @@ fn logging_requires_at_least_one_sink() {
assert!(
config
.resolve_and_validate(std::path::Path::new("/tmp"))
.resolve_and_validate(
std::path::Path::new("/tmp"),
std::path::Path::new("/tmp/web/dist"),
)
.is_err()
);
}
@@ -550,7 +550,7 @@ async fn update_should_remove_stale_file_lock_and_continue() {
}
#[tokio::test]
async fn update_should_replace_local_release_files_with_latest_asset() {
async fn update_and_rollback_should_use_the_default_api_asset_directory() {
let server = MockServer::start().await;
let fixture = Fixture::new();
fixture
@@ -561,8 +561,21 @@ async fn update_should_replace_local_release_files_with_latest_asset() {
ChecksumKind::Valid,
)
.await;
fixture
.service(&server)
let mut host: gateway_host::HostConfig = serde_json::from_value(serde_json::json!({
"listen": { "host": "127.0.0.1", "port": 8080 },
"runtime_data_dir": "../.runtime/data",
"logging": {
"level": "info", "stdout": true,
"file": { "enabled": false, "directory": "../.runtime/logs", "max_file_size_mb": 20 }
}
}))
.expect("binary host config");
host.system_update = fixture.config(&format!("{}/repos", server.uri()));
host.system_update.web_dist_dir = None;
host.resolve_and_validate(&fixture.root.path().join("deploy"), &fixture.web())
.expect("inherit API asset directory");
let service = ProcessSystemOperations::new(CancellationToken::new(), host.system_update);
service
.perform_update(Some(TARGET_VERSION.to_owned()))
.await
.expect("update");
@@ -575,6 +588,16 @@ async fn update_should_replace_local_release_files_with_latest_asset() {
fs::read(fixture.web().join("index.html")).expect("web"),
b"new-web"
);
service.rollback().await.expect("rollback");
assert_eq!(
fs::read(fixture.executable()).expect("binary"),
b"old-binary"
);
assert_eq!(
fs::read(fixture.web().join("index.html")).expect("web"),
b"old-web"
);
}
#[tokio::test]
@@ -593,17 +616,18 @@ async fn update_should_replace_web_assets_across_filesystems() {
)
.await;
let mut config = fixture.config(&format!("{}/repos", server.uri()));
config.web_dist_dir = external.path().join("dist");
fs::create_dir_all(&config.web_dist_dir).expect("web dir");
fs::write(config.web_dist_dir.join("index.html"), "old-web").expect("web");
let service = ProcessSystemOperations::new(CancellationToken::new(), config.clone());
let web_dist = external.path().join("dist");
config.web_dist_dir = Some(web_dist.clone());
fs::create_dir_all(&web_dist).expect("web dir");
fs::write(web_dist.join("index.html"), "old-web").expect("web");
let service = ProcessSystemOperations::new(CancellationToken::new(), config);
service
.perform_update(Some(TARGET_VERSION.to_owned()))
.await
.expect("update");
assert_eq!(
fs::read(config.web_dist_dir.join("index.html")).expect("web"),
fs::read(web_dist.join("index.html")).expect("web"),
b"new-web"
);
}
@@ -726,7 +750,7 @@ impl Fixture {
update_repository: Some("owner/repository".to_owned()),
github_api_base: api_base.to_owned(),
executable_path: Some(self.executable()),
web_dist_dir: self.web(),
web_dist_dir: Some(self.web()),
update_state_file: self.state(),
update_lock_file: self.lock(),
update_temp_dir: self.root.path().join("tmp"),
+3 -1
View File
@@ -337,6 +337,7 @@ OpenAI 主动额度重置卡及其消费结果由上游持有,不写入 Postgr
每个 Release 独立提供 `config.example.yaml`、默认镜像固定到该版本的 `compose.yaml` 和校验和;
各平台归档也包含 `deploy/config.example.yaml`。配置模板来自构建该版本的同一提交。
使用二进制归档手动部署时,将模板中的 `api.asset_directory` 改为 `../web/dist`,指向归档内的静态资源。
在线更新默认使用同一目录;如显式设置 `host.system_update.web_dist_dir`,应确保它指向实际提供页面的目录。
升级时先阅读目标版本说明,下载同一 Release 的部署附件,对比模板并合并必要配置,保留已有凭据
和 Compose 自定义项。不要用模板覆盖 `config.yaml`,也不要从 `main` 下载模板搭配旧镜像。
@@ -375,7 +376,8 @@ Compose 已显式装配正式发布构建所需的运行参数:
- `CPR_UPDATE_REPOSITORY`:只接受 `owner/repository`;默认 `zyycn/codex-proxy-rs`。
- `CPR_GITHUB_API_BASE`:正式环境必须为 `https://api.github.com/repos`。
- `CPR_UPDATE_CHANNEL`:`stable` 会拒绝 prerelease。
- `CPR_UPDATE_EXE_PATH`、`CPR_WEB_DIST_DIR`:分别指向容器内二进制和前端静态目录。
- `CPR_UPDATE_EXE_PATH`、`CPR_WEB_DIST_DIR`:分别指向容器内二进制和前端静态目录;
`CPR_WEB_DIST_DIR` 同时供页面服务与更新器使用,相对路径以 `deploy/config.yaml` 所在目录为基准。
- 更新临时目录、状态文件和锁文件默认由 `host.runtime_data_dir` 派生;
`CPR_UPDATE_TEMP_DIR`、`CPR_UPDATE_STATE_FILE`、`CPR_UPDATE_LOCK_FILE` 仅用于显式覆盖。
- `CPR_ENABLE_SELF_RESTART=true`:更新或回滚完成后允许管理端请求重启;Docker 进程退出后由