From 2956f07723d935220b29fca3a5dccd53303f3755 Mon Sep 17 00:00:00 2001 From: Zyy Date: Sat, 26 Sep 2026 04:40:30 +0800 Subject: [PATCH] feat(system): add update channels and reconcile installation state Co-authored-by: Codex --- .agents/skills/cpr-release/SKILL.md | 6 +- .../crates/gateway-admin/src/model/system.rs | 35 +++ .../crates/gateway-admin/src/ports/system.rs | 6 +- .../gateway-admin/src/use_case/system.rs | 23 +- .../gateway-admin/tests/use_case/mod.rs | 7 +- .../tests/use_case/plugin_update.rs | 13 +- .../gateway-admin/tests/use_case/system.rs | 36 ++- .../crates/gateway-api/src/admin/system.rs | 20 +- backend/crates/gateway-api/tests/admin/mod.rs | 7 +- .../crates/gateway-api/tests/admin/system.rs | 62 +++- backend/crates/gateway-api/tests/support.rs | 8 +- .../src/system_update/installation.rs | 103 ++++++ .../gateway-host/src/system_update/mod.rs | 109 +++++-- .../gateway-host/src/system_update/release.rs | 126 ++++---- .../gateway-host/src/system_update/state.rs | 71 ++++- .../gateway-host/src/system_update/swap.rs | 2 +- .../tests/system_update/channels.rs | 292 ++++++++++++++++++ .../tests/system_update/installation.rs | 148 +++++++++ .../gateway-host/tests/system_update/mod.rs | 111 ++++--- .../runtime/tests/support/environment.rs | 7 +- deploy/README.md | 34 +- docs/api.md | 16 +- docs/architecture.md | 7 +- frontend/src/api/modules/system.ts | 12 +- .../components/SystemUpdateModal/index.vue | 135 ++++++-- .../components/SystemUpdateModal/presenter.ts | 17 +- frontend/src/stores/modules/system-update.ts | 142 ++++++--- 27 files changed, 1304 insertions(+), 251 deletions(-) create mode 100644 backend/crates/gateway-host/src/system_update/installation.rs create mode 100644 backend/crates/gateway-host/tests/system_update/channels.rs create mode 100644 backend/crates/gateway-host/tests/system_update/installation.rs diff --git a/.agents/skills/cpr-release/SKILL.md b/.agents/skills/cpr-release/SKILL.md index 2917d6562..8c92e6afb 100644 --- a/.agents/skills/cpr-release/SKILL.md +++ b/.agents/skills/cpr-release/SKILL.md @@ -46,9 +46,9 @@ description: 准备、执行和排查 Codex Proxy RS 发版。用户说“发个 - alpha 是下一正式版本的早期开发阶段,beta 是功能测试阶段,rc 是发布候选,exp 是临时功能实验。 预发行采用 `vX.Y.Z-<阶段>.N`,`N` 从 1 开始;正式版不带预发行后缀,不新增未约定的通道名称。 -- 正式版只接收同一大版本内更新的正式版。alpha → beta → rc → 正式版只在相同 `X.Y.Z` 内晋级, - 可以跳过阶段,不能倒退;转正后不自动进入下一轮预发行。exp 仅接收同基线、同一轮实验的后续编号, - 不能跨实验线或转入其他阶段;不同实验分支不得共用一个 `X.Y.Z-exp.N` 系列。 +- 更新候选由本次检查或安装选择的通道决定:Stable 仅正式版,RC 包含正式版,Beta 包含 RC 与正式版,Alpha 包含所有常规阶段。 + 仅接收同一大版本内严格更高的版本,可跟随后续小版本与补丁版本,默认按运行版本推导通道;临时选择不保存、不允许降级。 + exp 仅接收同基线、同一轮实验的后续编号,不能跨实验线或转入其他阶段;不同实验分支不得共用一个 `X.Y.Z-exp.N` 系列。 - 核对目标工作流的构建类型:正式版及 alpha、beta、rc 为 `release`,exp 为 `experimental`。 所有预发行均标记为 Pre-release,不设置 GitHub Latest、不覆盖镜像 `latest`。 发布预发行前记录这两个稳定入口,发布后回读确认未改变。 diff --git a/backend/crates/gateway-admin/src/model/system.rs b/backend/crates/gateway-admin/src/model/system.rs index 365670fa0..d468e15ec 100644 --- a/backend/crates/gateway-admin/src/model/system.rs +++ b/backend/crates/gateway-admin/src/model/system.rs @@ -1,6 +1,40 @@ //! 版本、自更新、回滚与进程重启的 UTC 语义模型。 use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// 本次检查或安装选择的更新稳定性范围,具体版本准入由 Host 判定。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum SystemUpdateChannel { + Stable, + Alpha, + Beta, + Rc, + #[serde(rename = "exp")] + Experimental, +} + +impl SystemUpdateChannel { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Stable => "stable", + Self::Alpha => "alpha", + Self::Beta => "beta", + Self::Rc => "rc", + Self::Experimental => "exp", + } + } +} + +/// Host 返回的本次检查通道及当前构建允许选择的范围。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SystemUpdatePolicy { + pub channel: SystemUpdateChannel, + pub available_channels: Vec, +} /// 当前运行版本及更新检查摘要。 /// @@ -23,6 +57,7 @@ pub struct SystemVersion { /// 部署模式与构建类型的展示标签归 API;其余字段均为 Host 已确认的原始事实。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct SystemUpdateDetail { + pub policy: SystemUpdatePolicy, pub current_version: String, pub latest_version: String, pub has_update: bool, diff --git a/backend/crates/gateway-admin/src/ports/system.rs b/backend/crates/gateway-admin/src/ports/system.rs index 69e274fd8..07257bb7c 100644 --- a/backend/crates/gateway-admin/src/ports/system.rs +++ b/backend/crates/gateway-admin/src/ports/system.rs @@ -7,8 +7,8 @@ use futures::Stream; use crate::model::Revision; use crate::model::system::{ - SystemOperationAccepted, SystemUpdateDetail, SystemUpdateEvent, SystemUpdateStatus, - SystemVersion, + SystemOperationAccepted, SystemUpdateChannel, SystemUpdateDetail, SystemUpdateEvent, + SystemUpdateStatus, SystemVersion, }; /// Host 已校验并解包的同一更新候选;字节只含发行清单,不含运行配置或凭据。 @@ -89,6 +89,7 @@ pub trait SystemOperations: Send + Sync { async fn update_detail( &self, refresh: bool, + channel: Option, ) -> Result; fn update_events(&self) -> SystemUpdateEventStream; @@ -96,6 +97,7 @@ pub trait SystemOperations: Send + Sync { async fn perform_update( &self, target_version: Option, + channel: Option, preflight: Arc, ) -> Result; diff --git a/backend/crates/gateway-admin/src/use_case/system.rs b/backend/crates/gateway-admin/src/use_case/system.rs index 27dffce97..3e66a812d 100644 --- a/backend/crates/gateway-admin/src/use_case/system.rs +++ b/backend/crates/gateway-admin/src/use_case/system.rs @@ -7,7 +7,10 @@ use async_trait::async_trait; use crate::{ model::{ AdminError, AdminErrorKind, - system::{SystemOperationAccepted, SystemUpdateDetail, SystemUpdateStatus, SystemVersion}, + system::{ + SystemOperationAccepted, SystemUpdateChannel, SystemUpdateDetail, SystemUpdateStatus, + SystemVersion, + }, }, ports::system::{ SystemOperationError, SystemOperationErrorKind, SystemOperations, SystemUpdateEventStream, @@ -19,11 +22,16 @@ use crate::{ #[async_trait] pub trait SystemService: Send + Sync { async fn version(&self) -> Result; - async fn update_detail(&self, refresh: bool) -> Result; + async fn update_detail( + &self, + refresh: bool, + channel: Option, + ) -> Result; fn update_events(&self) -> SystemUpdateEventStream; async fn perform_update( &self, target_version: Option, + channel: Option, ) -> Result; async fn update_status(&self) -> Result; async fn rollback(&self) -> Result; @@ -55,9 +63,13 @@ impl SystemService for DefaultSystemService { self.operations.version().await.map_err(map_system_error) } - async fn update_detail(&self, refresh: bool) -> Result { + async fn update_detail( + &self, + refresh: bool, + channel: Option, + ) -> Result { self.operations - .update_detail(refresh) + .update_detail(refresh, channel) .await .map_err(map_system_error) } @@ -69,12 +81,13 @@ impl SystemService for DefaultSystemService { async fn perform_update( &self, target_version: Option, + channel: Option, ) -> Result { let target_version = target_version .map(|version| version.trim().to_owned()) .filter(|version| !version.is_empty()); self.operations - .perform_update(target_version, Arc::clone(&self.preflight)) + .perform_update(target_version, channel, Arc::clone(&self.preflight)) .await .map_err(map_system_error) } diff --git a/backend/crates/gateway-admin/tests/use_case/mod.rs b/backend/crates/gateway-admin/tests/use_case/mod.rs index 0e35fe465..65b2e3581 100644 --- a/backend/crates/gateway-admin/tests/use_case/mod.rs +++ b/backend/crates/gateway-admin/tests/use_case/mod.rs @@ -1035,7 +1035,11 @@ impl SystemOperations for UnavailableSystem { Err(unavailable_system()) } - async fn update_detail(&self, _: bool) -> Result { + async fn update_detail( + &self, + _: bool, + _: Option, + ) -> Result { Err(unavailable_system()) } @@ -1046,6 +1050,7 @@ impl SystemOperations for UnavailableSystem { async fn perform_update( &self, _: Option, + _: Option, _: Arc, ) -> Result { Err(unavailable_system()) diff --git a/backend/crates/gateway-admin/tests/use_case/plugin_update.rs b/backend/crates/gateway-admin/tests/use_case/plugin_update.rs index 4824e769a..615520143 100644 --- a/backend/crates/gateway-admin/tests/use_case/plugin_update.rs +++ b/backend/crates/gateway-admin/tests/use_case/plugin_update.rs @@ -225,7 +225,11 @@ impl SystemOperations for PreflightingSystem { unreachable!() } - async fn update_detail(&self, _: bool) -> Result { + async fn update_detail( + &self, + _: bool, + _: Option, + ) -> Result { unreachable!() } @@ -236,6 +240,7 @@ impl SystemOperations for PreflightingSystem { async fn perform_update( &self, _: Option, + _: Option, preflight: Arc, ) -> Result { let revision = preflight.validate(self.candidate.clone()).await?; @@ -291,7 +296,7 @@ async fn system_update_accepts_compatible_enabled_plugins_at_the_same_revision() services .system() - .perform_update(Some("1.2.0".into())) + .perform_update(Some("1.2.0".into()), None) .await .expect("compatible update"); } @@ -309,7 +314,7 @@ async fn system_update_rejects_missing_target_capability_and_revision_changes() assert!( services .system() - .perform_update(Some("1.2.0".into())) + .perform_update(Some("1.2.0".into()), None) .await .is_err() ); @@ -325,7 +330,7 @@ async fn system_update_rejects_missing_target_capability_and_revision_changes() assert!( services .system() - .perform_update(Some("1.2.0".into())) + .perform_update(Some("1.2.0".into()), None) .await .is_err() ); diff --git a/backend/crates/gateway-admin/tests/use_case/system.rs b/backend/crates/gateway-admin/tests/use_case/system.rs index 633d2a843..145534647 100644 --- a/backend/crates/gateway-admin/tests/use_case/system.rs +++ b/backend/crates/gateway-admin/tests/use_case/system.rs @@ -13,6 +13,7 @@ use gateway_admin::{ #[derive(Default)] struct RecordingSystemOperations { target: Mutex>>, + channel: Mutex>, } #[async_trait] @@ -24,6 +25,7 @@ impl SystemOperations for RecordingSystemOperations { build_time: "unknown".to_owned(), deployment_mode: "source".to_owned(), update_channel: "stable".to_owned(), + latest_version: "1.0.0".to_owned(), has_update: false, update_cached: false, @@ -31,8 +33,16 @@ impl SystemOperations for RecordingSystemOperations { }) } - async fn update_detail(&self, _: bool) -> Result { + async fn update_detail( + &self, + _: bool, + _: Option, + ) -> Result { Ok(SystemUpdateDetail { + policy: gateway_admin::model::system::SystemUpdatePolicy { + channel: gateway_admin::model::system::SystemUpdateChannel::Stable, + available_channels: vec![gateway_admin::model::system::SystemUpdateChannel::Stable], + }, current_version: "1.0.0".to_owned(), latest_version: "1.0.0".to_owned(), has_update: false, @@ -54,9 +64,11 @@ impl SystemOperations for RecordingSystemOperations { async fn perform_update( &self, target_version: Option, + channel: Option, _: std::sync::Arc, ) -> Result { *self.target.lock().expect("target") = Some(target_version.clone()); + *self.channel.lock().expect("channel") = channel; Ok(SystemOperationAccepted::Update { operation_id: "operation-update".to_owned(), deployment_mode: "source".to_owned(), @@ -111,9 +123,29 @@ async fn system_update_should_normalize_blank_target_to_latest() { .await; services .system() - .perform_update(Some(" ".to_owned())) + .perform_update(Some(" ".to_owned()), None) .await .expect("perform update"); assert_eq!(*operations.target.lock().expect("target"), Some(None)); } + +#[tokio::test] +async fn system_update_should_forward_the_confirmed_channel() { + let operations = std::sync::Arc::new(RecordingSystemOperations::default()); + let services = super::AdminHarness::new() + .system(operations.clone()) + .build() + .await; + let channel = gateway_admin::model::system::SystemUpdateChannel::Beta; + services + .system() + .perform_update(Some(" 1.2.0-beta.1 ".into()), Some(channel)) + .await + .expect("accepted"); + assert_eq!( + *operations.target.lock().expect("target"), + Some(Some("1.2.0-beta.1".into())) + ); + assert_eq!(*operations.channel.lock().expect("channel"), Some(channel)); +} diff --git a/backend/crates/gateway-api/src/admin/system.rs b/backend/crates/gateway-api/src/admin/system.rs index e89885081..d0940f07b 100644 --- a/backend/crates/gateway-api/src/admin/system.rs +++ b/backend/crates/gateway-api/src/admin/system.rs @@ -20,8 +20,8 @@ use axum::{ use futures::{Stream, StreamExt}; use gateway_admin::model::system::{ SystemOperationAccepted, SystemOperationKind, SystemOperationState, SystemOperationStatus, - SystemUpdateDetail, SystemUpdateEvent, SystemUpdateEventLevel, SystemUpdateStatus, - SystemVersion, + SystemUpdateChannel, SystemUpdateDetail, SystemUpdateEvent, SystemUpdateEventLevel, + SystemUpdatePolicy, SystemUpdateStatus, SystemVersion, }; use serde::{Deserialize, Serialize}; @@ -35,9 +35,15 @@ use super::{ #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct UpdateDetailQuery { refresh: Option, + channel: Option, } impl UpdateDetailQuery { + #[must_use] + pub const fn channel(&self) -> Option { + self.channel + } + /// 是否强制从发布源刷新。 #[must_use] pub fn refresh(&self) -> bool { @@ -50,6 +56,7 @@ impl UpdateDetailQuery { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct UpdateRequest { target_version: String, + channel: Option, } impl UpdateRequest { @@ -95,6 +102,7 @@ impl From for SystemVersionView { #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct SystemUpdateDetailView { + policy: SystemUpdatePolicy, current_version: String, latest_version: String, has_update: bool, @@ -115,6 +123,7 @@ impl From for SystemUpdateDetailView { Self { deployment_mode_label: deployment_mode_label(&detail.deployment_mode).to_owned(), build_type_label: build_type_label(&detail.build_type), + policy: detail.policy, current_version: detail.current_version, latest_version: detail.latest_version, has_update: detail.has_update, @@ -263,7 +272,7 @@ where let detail = state .admin_services() .system() - .update_detail(query.refresh()) + .update_detail(query.refresh(), query.channel()) .await .map_err(map_system_error)?; Ok(AdminResponse::new( @@ -299,10 +308,13 @@ async fn perform_update( where S: SessionState + Send + Sync, { + let (target, channel) = payload.map_or((None, None), |AdminJson(value)| { + (Some(value.target_version), value.channel) + }); let result = state .admin_services() .system() - .perform_update(payload.map(|AdminJson(value)| value.into_target_version())) + .perform_update(target, channel) .await .map_err(map_system_error)?; let SystemOperationAccepted::Update { diff --git a/backend/crates/gateway-api/tests/admin/mod.rs b/backend/crates/gateway-api/tests/admin/mod.rs index cfca69b3d..f5b8704a9 100644 --- a/backend/crates/gateway-api/tests/admin/mod.rs +++ b/backend/crates/gateway-api/tests/admin/mod.rs @@ -1456,7 +1456,11 @@ impl SystemOperations for UnusedSystem { Err(unavailable_system()) } - async fn update_detail(&self, _: bool) -> Result { + async fn update_detail( + &self, + _: bool, + _: Option, + ) -> Result { Err(unavailable_system()) } @@ -1467,6 +1471,7 @@ impl SystemOperations for UnusedSystem { async fn perform_update( &self, _: Option, + _: Option, _: Arc, ) -> Result { Err(unavailable_system()) diff --git a/backend/crates/gateway-api/tests/admin/system.rs b/backend/crates/gateway-api/tests/admin/system.rs index b3f77b3bd..af1f2f130 100644 --- a/backend/crates/gateway-api/tests/admin/system.rs +++ b/backend/crates/gateway-api/tests/admin/system.rs @@ -125,8 +125,29 @@ impl SystemOperations for ProgressSystem { Err(unavailable_system()) } - async fn update_detail(&self, _: bool) -> Result { - Err(unavailable_system()) + async fn update_detail( + &self, + _: bool, + channel: Option, + ) -> Result { + use gateway_admin::model::system::{SystemUpdateChannel, SystemUpdatePolicy}; + Ok(SystemUpdateDetail { + policy: SystemUpdatePolicy { + channel: channel.unwrap_or(SystemUpdateChannel::Stable), + available_channels: vec![SystemUpdateChannel::Stable, SystemUpdateChannel::Beta], + }, + current_version: "0.1.0".into(), + latest_version: "0.2.0".into(), + has_update: true, + deployment_mode: "docker".into(), + build_type: "release".into(), + release_url: None, + notes: None, + cached: false, + update_supported: true, + unsupported_reason: None, + warning: None, + }) } fn update_events(&self) -> SystemUpdateEventStream { @@ -147,6 +168,7 @@ impl SystemOperations for ProgressSystem { async fn perform_update( &self, target: Option, + _: Option, _: Arc, ) -> Result { Ok(SystemOperationAccepted::Update { @@ -186,3 +208,39 @@ impl SystemOperations for ProgressSystem { Err(unavailable_system()) } } + +#[tokio::test] +async fn update_detail_should_validate_and_forward_the_temporary_channel() { + let fixture = AdminTestFixture::with_system(Arc::new(ProgressSystem)).await; + fixture.auth.insert_session("valid-session"); + let router = app(fixture.state()); + for (authenticated, query, expected) in [ + (false, "channel=beta", StatusCode::UNAUTHORIZED), + (true, "channel=nightly", StatusCode::BAD_REQUEST), + (true, "channel=beta&extra=true", StatusCode::BAD_REQUEST), + (true, "channel=beta", StatusCode::OK), + (true, "", StatusCode::OK), + ] { + let mut request = Request::builder() + .uri(format!("/api/admin/system/update/detail?{query}")) + .header("x-request-id", "req_channel_check"); + if authenticated { + request = request.header(header::COOKIE, "cpr_session=valid-session"); + } + let response = router + .clone() + .oneshot(request.body(Body::empty()).expect("request")) + .await + .expect("response"); + assert_eq!(response.status(), expected); + if expected == StatusCode::OK { + let body: serde_json::Value = + serde_json::from_slice(&to_bytes(response.into_body(), 4096).await.expect("body")) + .expect("json"); + assert_eq!( + body["data"]["policy"]["channel"], + if query.is_empty() { "stable" } else { "beta" } + ); + } + } +} diff --git a/backend/crates/gateway-api/tests/support.rs b/backend/crates/gateway-api/tests/support.rs index 0e6adcefa..9b9a04497 100644 --- a/backend/crates/gateway-api/tests/support.rs +++ b/backend/crates/gateway-api/tests/support.rs @@ -93,6 +93,7 @@ impl SystemOperations for VersionSystem { build_time: "internal-build-time".to_owned(), deployment_mode: "binary".to_owned(), update_channel: "release".to_owned(), + latest_version: "3.8.0".to_owned(), has_update: true, update_cached: true, @@ -100,7 +101,11 @@ impl SystemOperations for VersionSystem { }) } - async fn update_detail(&self, _: bool) -> Result { + async fn update_detail( + &self, + _: bool, + _: Option, + ) -> Result { unreachable!("client route must not request update details") } @@ -111,6 +116,7 @@ impl SystemOperations for VersionSystem { async fn perform_update( &self, _: Option, + _: Option, _: std::sync::Arc, ) -> Result { unreachable!("client route must not perform updates") diff --git a/backend/crates/gateway-host/src/system_update/installation.rs b/backend/crates/gateway-host/src/system_update/installation.rs new file mode 100644 index 000000000..a009f0a1d --- /dev/null +++ b/backend/crates/gateway-host/src/system_update/installation.rs @@ -0,0 +1,103 @@ +//! 安装文件指纹用于区分本进程安装的候选与外部重新部署,历史版本号不代表磁盘事实。 + +use std::fs; +use std::io::{self, Read}; +use std::path::Path; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; + +use super::swap::backup_path_for; +use super::{OperationError, SystemUpdateConfig, conflict, internal}; + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ReleaseFiles { + binary: String, + web: String, + official_plugins: String, +} + +impl ReleaseFiles { + pub(crate) fn installed(config: &SystemUpdateConfig) -> Result { + Self::read( + &config.executable_path()?, + config.web_dist_dir()?, + &config.official_plugins_dir()?, + ) + } + + pub(crate) fn backup(config: &SystemUpdateConfig) -> Result { + Self::read( + &backup_path_for(&config.executable_path()?), + &backup_path_for(config.web_dist_dir()?), + &backup_path_for(&config.official_plugins_dir()?), + ) + } + + fn read(binary: &Path, web: &Path, plugins: &Path) -> Result { + if !web.join("index.html").is_file() + || !plugins.join(super::OFFICIAL_PLUGIN_MANIFEST).is_file() + { + return Err(conflict("安装文件不完整,请核对部署后重试")); + } + let mut binary_hash = Sha256::new(); + hash_file(binary, &mut binary_hash).map_err(read_error)?; + Ok(Self { + binary: hex::encode(binary_hash.finalize()), + web: hash_directory(web).map_err(read_error)?, + official_plugins: hash_directory(plugins).map_err(read_error)?, + }) + } +} + +fn read_error(error: io::Error) -> OperationError { + tracing::warn!(error = %error, "核对系统安装文件失败"); + internal("无法核对安装文件,请检查文件完整性与读取权限") +} + +fn hash_directory(path: &Path) -> io::Result { + let mut hash = Sha256::new(); + hash_tree(path, &mut hash)?; + Ok(hex::encode(hash.finalize())) +} + +fn hash_tree(path: &Path, hash: &mut Sha256) -> io::Result<()> { + let mut entries = fs::read_dir(path)?.collect::, _>>()?; + entries.sort_by_key(fs::DirEntry::file_name); + for entry in entries { + let name = entry.file_name(); + let name = name.as_encoded_bytes(); + hash.update((name.len() as u64).to_le_bytes()); + hash.update(name); + if entry.file_type()?.is_dir() { + hash.update(b"directory"); + hash_tree(&entry.path(), hash)?; + hash.update(b"end-directory"); + } else { + hash.update(b"file"); + hash_file(&entry.path(), hash)?; + } + } + Ok(()) +} + +fn hash_file(path: &Path, hash: &mut Sha256) -> io::Result<()> { + let metadata = fs::symlink_metadata(path)?; + if !metadata.is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "发行文件必须为普通文件", + )); + } + hash.update(metadata.len().to_le_bytes()); + let mut file = fs::File::open(path)?; + let mut buffer = [0; 64 * 1024]; + loop { + let count = file.read(&mut buffer)?; + if count == 0 { + return Ok(()); + } + hash.update(&buffer[..count]); + } +} diff --git a/backend/crates/gateway-host/src/system_update/mod.rs b/backend/crates/gateway-host/src/system_update/mod.rs index 7bc1ece9d..3502d8c0d 100644 --- a/backend/crates/gateway-host/src/system_update/mod.rs +++ b/backend/crates/gateway-host/src/system_update/mod.rs @@ -2,6 +2,7 @@ mod archive; mod download; +mod installation; mod process; mod release; mod state; @@ -17,8 +18,9 @@ use std::time::Duration; use async_trait::async_trait; use chrono::Utc; use gateway_admin::model::system::{ - SystemOperationAccepted, SystemOperationKind, SystemUpdateDetail, SystemUpdateEvent, - SystemUpdateEventLevel, SystemUpdateStatus, SystemVersion, + SystemOperationAccepted, SystemOperationKind, SystemUpdateChannel as UpdateChannel, + SystemUpdateDetail, SystemUpdateEvent, SystemUpdateEventLevel, SystemUpdatePolicy, + SystemUpdateStatus, SystemVersion, }; use gateway_admin::ports::system::{ SystemOperationError, SystemOperationErrorKind, SystemOperations, SystemUpdateCandidate, @@ -32,14 +34,15 @@ use crate::config::ConfigError; use self::archive::extract_release; use self::download::{MAX_CHECKSUM_SIZE, MAX_DOWNLOAD_SIZE, download_file, verify_checksum}; +use self::installation::ReleaseFiles; use self::process::{environment_value, spawn_replacement}; use self::release::{ - ReleaseCache, UpdateChannel, confirmed_target, detail_from_release, fetch_latest, - select_archive, validate_update_target, version_channel, + ReleaseCache, confirmed_target, detail_from_release, fetch_latest, select_archive, + validate_update_target, version_channel, }; use self::state::{ OperationFileLock, UpdateOperation, UpdateTempDir, finish, operation_id, read_status, - recover_interrupted, set_running, + reconcile_installation, recover_interrupted, set_running, }; use self::swap::{replace_release_files, rollback_official_plugins_dir, rollback_release}; @@ -233,23 +236,28 @@ pub struct ProcessSystemOperations { config: Arc, operation_lock: Arc>, release_cache: Arc, + running_files: Arc>, } impl ProcessSystemOperations { #[must_use] pub fn new(cancellation: CancellationToken, config: SystemUpdateConfig) -> Self { + // 组合根在接收请求前创建服务,记录本次启动的发行文件,后续替换不能改写这份事实。 + let running_files = Arc::new(ReleaseFiles::installed(&config)); Self { cancellation, events: Arc::new(UpdateEvents::default()), config: Arc::new(config), operation_lock: Arc::new(AsyncMutex::new(())), release_cache: Arc::new(ReleaseCache::default()), + running_files, } } fn start_update( &self, target_version: Option, + channel: Option, preflight: Arc, ) -> Result { let operation_lock = Arc::clone(&self.operation_lock) @@ -264,13 +272,14 @@ impl ProcessSystemOperations { return Err(conflict(reason)); } let target = confirmed_target(target_version)?; - if let Err(error) = validate_update_target(&self.config.version, &target) { + let file_lock = OperationFileLock::acquire(&self.config.update_lock_file)?; + let selected = self.resolve_channel(channel)?; + if let Err(error) = validate_update_target(&self.config.version, &target, selected) { self.events .error_terminal(None, Some("preflight"), error.to_string()); return Err(error); } - let file_lock = OperationFileLock::acquire(&self.config.update_lock_file)?; - let status = read_status(&self.config.update_state_file, &self.config.version)?; + let status = self.reconcile_installation()?; if status.need_restart { return Err(conflict("更新已完成,请先重启服务")); } @@ -295,14 +304,14 @@ impl ProcessSystemOperations { let result = tokio::select! { biased; () = service.cancellation.cancelled() => Err(conflict("服务关闭,更新已中断")), - result = service.perform_update_inner(&target, &operation_id, preflight.as_ref()) => result, + result = service.perform_update_inner(&target, selected, &operation_id, preflight.as_ref()) => result, }; if let Err(error) = operation.complete(&result) { tracing::warn!(error = %error, "系统更新终态落盘失败"); return; } match result { - Ok(()) => service.events.success_terminal( + Ok(_) => service.events.success_terminal( Some(&operation_id), Some("done"), "更新文件已替换,等待服务重启生效", @@ -320,9 +329,10 @@ impl ProcessSystemOperations { async fn perform_update_inner( &self, target: &str, + channel: UpdateChannel, operation_id: &str, preflight: &dyn SystemUpdatePreflight, - ) -> Result<(), OperationError> { + ) -> Result { let repository = self .config .update_repository @@ -337,10 +347,11 @@ impl ProcessSystemOperations { &self.config.github_api_base, repository, &self.config.version, + channel, ) .await? .ok_or_else(|| conflict("当前发行通道没有可用更新"))?; - let detail = detail_from_release(&self.config, &release); + let detail = detail_from_release(&self.config, &release, channel); if detail.latest_version != target { return Err(conflict("远端最新版本已变化,请重新确认")); } @@ -362,7 +373,7 @@ impl ProcessSystemOperations { version: &str, operation_id: &str, preflight: &dyn SystemUpdatePreflight, - ) -> Result<(), OperationError> { + ) -> Result { self.events.info( Some(operation_id), Some("asset"), @@ -461,24 +472,46 @@ impl ProcessSystemOperations { if let Err(error) = preflight.confirm_revision(plugin_revision).await { return Err(applied.rollback(error)); } + let files = ReleaseFiles::installed(&self.config)?; applied.commit(); self.events .success(Some(operation_id), Some("replace"), "应用文件替换完成"); - Ok(()) + Ok(files) + } + + fn resolve_channel( + &self, + requested: Option, + ) -> Result { + let channel = requested.unwrap_or_else(|| { + version_channel(&self.config.version).unwrap_or(UpdateChannel::Stable) + }); + if !update_policy(&self.config, channel) + .available_channels + .contains(&channel) + { + return Err(conflict("当前发行版本不支持此更新通道")); + } + Ok(channel) + } + + fn reconcile_installation(&self) -> Result { + let running_files = self.running_files.as_ref().as_ref().map_err(Clone::clone)?; + reconcile_installation(&self.config, running_files) } } #[async_trait] impl SystemOperations for ProcessSystemOperations { async fn version(&self) -> Result { - let detail = self.update_detail(false).await?; + let detail = self.update_detail(false, None).await?; Ok(SystemVersion { version: self.config.version.clone(), git_sha: self.config.git_sha.clone(), build_time: self.config.build_time.clone(), deployment_mode: self.config.deployment_mode.clone(), update_channel: version_channel(&self.config.version) - .map(|channel| channel.label().to_owned()) + .map(|channel| channel.as_str().to_owned()) .unwrap_or_else(|| "unknown".to_owned()), latest_version: detail.latest_version, has_update: detail.has_update, @@ -487,11 +520,21 @@ impl SystemOperations for ProcessSystemOperations { }) } - async fn update_detail(&self, refresh: bool) -> Result { - match self.release_cache.detail(&self.config, refresh).await { + async fn update_detail( + &self, + refresh: bool, + channel: Option, + ) -> Result { + let channel = self.resolve_channel(channel)?; + match self + .release_cache + .detail(&self.config, refresh, channel) + .await + { Ok(detail) => Ok(detail), Err(error) => Ok(base_update_detail( &self.config, + channel, self.config.update_support_error(), Some(error.to_string()), )), @@ -520,9 +563,10 @@ impl SystemOperations for ProcessSystemOperations { async fn perform_update( &self, target_version: Option, + channel: Option, preflight: Arc, ) -> Result { - self.start_update(target_version, preflight) + self.start_update(target_version, channel, preflight) } async fn update_status(&self) -> Result { @@ -531,8 +575,15 @@ impl SystemOperations for ProcessSystemOperations { &self.config.update_state_file, &self.config.update_lock_file, )?; + if self.config.update_support_error().is_none() { + match OperationFileLock::acquire(&self.config.update_lock_file) { + Ok(_lock) => return self.reconcile_installation(), + Err(error) if error.kind() == SystemOperationErrorKind::Conflict => {} + Err(error) => return Err(error), + } + } } - read_status(&self.config.update_state_file, &self.config.version) + read_status(&self.config.update_state_file, false) } async fn rollback( @@ -548,7 +599,8 @@ impl SystemOperations for ProcessSystemOperations { } let operation_id = operation_id("rollback"); let file_lock = OperationFileLock::acquire(&self.config.update_lock_file)?; - let target_version = read_status(&self.config.update_state_file, &self.config.version)? + let target_version = self + .reconcile_installation()? .previous_version .ok_or_else(|| conflict("没有可用于回滚的上一版本"))?; let release_manifest = @@ -592,6 +644,7 @@ impl SystemOperations for ProcessSystemOperations { &operation_id, SystemOperationKind::Rollback, None, + None, result.as_ref().err().map(ToString::to_string), )?; match &result { @@ -700,12 +753,26 @@ impl Drop for AppliedReleaseGuard<'_> { } } +fn update_policy(config: &SystemUpdateConfig, channel: UpdateChannel) -> SystemUpdatePolicy { + use UpdateChannel::{Alpha, Beta, Experimental, Rc, Stable}; + SystemUpdatePolicy { + channel, + available_channels: if version_channel(&config.version) == Some(Experimental) { + vec![Experimental] + } else { + vec![Stable, Rc, Beta, Alpha] + }, + } +} + fn base_update_detail( config: &SystemUpdateConfig, + channel: UpdateChannel, unsupported_reason: Option, warning: Option, ) -> SystemUpdateDetail { SystemUpdateDetail { + policy: update_policy(config, channel), current_version: config.version.clone(), latest_version: config.version.clone(), has_update: false, diff --git a/backend/crates/gateway-host/src/system_update/release.rs b/backend/crates/gateway-host/src/system_update/release.rs index 9f364989d..7c83898b2 100644 --- a/backend/crates/gateway-host/src/system_update/release.rs +++ b/backend/crates/gateway-host/src/system_update/release.rs @@ -1,9 +1,10 @@ //! GitHub Release 发现、缓存、版本比较与下载信任边界。 use std::env; +use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{Duration, Instant}; -use gateway_admin::model::system::SystemUpdateDetail; +use gateway_admin::model::system::{SystemUpdateChannel as UpdateChannel, SystemUpdateDetail}; use serde::Deserialize; use tokio::sync::Mutex; @@ -12,27 +13,6 @@ use super::{OperationError, SystemUpdateConfig, conflict, invalid, upstream}; const APP_BINARY_NAME: &str = "codex-proxy-rs"; const CACHE_TTL: Duration = Duration::from_secs(20 * 60); -#[derive(Clone, Copy, PartialEq, Eq)] -pub(crate) enum UpdateChannel { - Stable, - Alpha, - Beta, - Rc, - Experimental, -} - -impl UpdateChannel { - pub(crate) fn label(&self) -> &str { - match self { - Self::Stable => "stable", - Self::Alpha => "alpha", - Self::Beta => "beta", - Self::Rc => "rc", - Self::Experimental => "exp", - } - } -} - #[derive(Debug, Clone, Deserialize)] pub(crate) struct GitHubRelease { pub(crate) tag_name: String, @@ -62,6 +42,7 @@ struct CachedRelease { #[derive(Default)] pub(crate) struct ReleaseCache { entry: Mutex>, + request_sequence: AtomicU64, } impl ReleaseCache { @@ -69,9 +50,15 @@ impl ReleaseCache { &self, config: &SystemUpdateConfig, refresh: bool, + channel: UpdateChannel, ) -> Result { if let Some(reason) = config.update_support_error() { - return Ok(super::base_update_detail(config, Some(reason), None)); + return Ok(super::base_update_detail( + config, + channel, + Some(reason), + None, + )); } let repository = config .update_repository @@ -79,26 +66,50 @@ impl ReleaseCache { .ok_or_else(|| conflict("update repository is not configured"))?; validate_repository(repository)?; validate_api_base(&config.github_api_base).map_err(conflict)?; - let key = config.release_cache_key(); + let key = format!("{}|{}", config.release_cache_key(), channel.as_str()); if !refresh && let Some(detail) = self.cached(&key).await { return Ok(detail); } - match fetch_latest(&config.github_api_base, repository, &config.version).await { + let sequence = self.request_sequence.fetch_add(1, Ordering::Relaxed) + 1; + match fetch_latest( + &config.github_api_base, + repository, + &config.version, + channel, + ) + .await + { Ok(release) => { let detail = release.as_ref().map_or_else( - || super::base_update_detail(config, config.update_support_error(), None), - |release| detail_from_release(config, release), + || { + super::base_update_detail( + config, + channel, + config.update_support_error(), + None, + ) + }, + |release| detail_from_release(config, release, channel), ); - *self.entry.lock().await = Some(CachedRelease { - key, - detail: detail.clone(), - cached_at: Instant::now(), - }); + let mut entry = self.entry.lock().await; + // 较慢的旧请求不能覆盖或清除新检查的结果,包括切走再切回同一通道。 + if self.request_sequence.load(Ordering::Relaxed) == sequence { + *entry = Some(CachedRelease { + key, + detail: detail.clone(), + cached_at: Instant::now(), + }); + } Ok(detail) } Err(error) => { // 强制检查失败后清掉旧结果,避免后续版本查询再次显示旧的更新标记。 - *self.entry.lock().await = None; + let mut entry = self.entry.lock().await; + if self.request_sequence.load(Ordering::Relaxed) == sequence + && entry.as_ref().is_some_and(|cached| cached.key == key) + { + *entry = None; + } Err(error) } } @@ -120,6 +131,7 @@ pub(crate) async fn fetch_latest( api_base: &str, repository: &str, current_version: &str, + channel: UpdateChannel, ) -> Result, OperationError> { validate_api_base(api_base).map_err(conflict)?; validate_repository(repository)?; @@ -143,7 +155,7 @@ pub(crate) async fn fetch_latest( .await?; let last_page = releases.len() < 100; for release in releases { - let Some(version) = eligible_release_version(&release, ¤t) else { + let Some(version) = eligible_release_version(&release, ¤t, channel) else { continue; }; if latest @@ -163,11 +175,12 @@ pub(crate) async fn fetch_latest( fn eligible_release_version( release: &GitHubRelease, current: &semver::Version, + channel: UpdateChannel, ) -> Option { let version = semver::Version::parse(&normalize_version(&release.tag_name)).ok()?; (!release.draft && release.prerelease != version.pre.is_empty() - && (version == *current || update_target_allowed(current, &version))) + && (version == *current || update_target_allowed(current, &version, channel))) .then_some(version) } @@ -197,16 +210,18 @@ async fn fetch_release_json( pub(crate) fn detail_from_release( config: &SystemUpdateConfig, release: &GitHubRelease, + channel: UpdateChannel, ) -> SystemUpdateDetail { let unsupported_reason = config.update_support_error(); let latest_version = normalize_version(&release.tag_name); - let has_update = validate_update_target(&config.version, &latest_version).is_ok(); + let has_update = validate_update_target(&config.version, &latest_version, channel).is_ok(); if unsupported_reason.is_some() || (!has_update && latest_version != normalize_version(&config.version)) { - return super::base_update_detail(config, unsupported_reason, None); + return super::base_update_detail(config, channel, unsupported_reason, None); } SystemUpdateDetail { + policy: super::update_policy(config, channel), current_version: config.version.clone(), latest_version, has_update, @@ -366,9 +381,12 @@ pub(crate) fn version_channel(version: &str) -> Option { } /// 检查更新与执行更新共用同一规则;构建元数据不构成更新。 -fn update_target_allowed(current: &semver::Version, target: &semver::Version) -> bool { +fn update_target_allowed( + current: &semver::Version, + target: &semver::Version, + selected: UpdateChannel, +) -> bool { use UpdateChannel::{Alpha, Beta, Experimental, Rc, Stable}; - if current.major != target.major || !target.cmp_precedence(current).is_gt() { return false; } @@ -377,30 +395,34 @@ fn update_target_allowed(current: &semver::Version, target: &semver::Version) -> else { return false; }; - if current_channel == Stable { - return target_channel == Stable; - } - // 预发行只承接本轮目标版本;exp 的相同基线也只代表同一轮实验。 - if (current.minor, current.patch) != (target.minor, target.patch) { - return false; + if current_channel == Experimental || selected == Experimental || target_channel == Experimental + { + return current_channel == Experimental + && selected == Experimental + && target_channel == Experimental + && (current.minor, current.patch) == (target.minor, target.patch); } matches!( - (current_channel, target_channel), - (Alpha, Alpha | Beta | Rc | Stable) - | (Beta, Beta | Rc | Stable) + (selected, target_channel), + (Stable, Stable) | (Rc, Rc | Stable) - | (Experimental, Experimental) + | (Beta, Beta | Rc | Stable) + | (Alpha, Alpha | Beta | Rc | Stable) ) } -pub(crate) fn validate_update_target(current: &str, target: &str) -> Result<(), OperationError> { +pub(crate) fn validate_update_target( + current: &str, + target: &str, + channel: UpdateChannel, +) -> Result<(), OperationError> { let current = semver::Version::parse(&normalize_version(current)) .map_err(|_| conflict("当前版本格式无效"))?; let target = semver::Version::parse(&normalize_version(target)) .map_err(|_| invalid("target version is invalid"))?; - if !update_target_allowed(¤t, &target) { + if !update_target_allowed(¤t, &target, channel) { return Err(conflict( - "当前版本不允许更新到此目标:不支持跨通道、跨发行线、跨大版本或降级", + "所选通道不允许此目标,或目标涉及跨实验线、跨大版本、降级", )); } Ok(()) diff --git a/backend/crates/gateway-host/src/system_update/state.rs b/backend/crates/gateway-host/src/system_update/state.rs index 82f617b86..22c56b733 100644 --- a/backend/crates/gateway-host/src/system_update/state.rs +++ b/backend/crates/gateway-host/src/system_update/state.rs @@ -12,7 +12,8 @@ use gateway_admin::model::system::{ }; use serde::{Deserialize, Serialize}; -use super::{OperationError, conflict, internal}; +use super::installation::ReleaseFiles; +use super::{OperationError, SystemUpdateConfig, conflict, internal}; #[derive(Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] @@ -20,6 +21,10 @@ struct PersistedState { previous_version: Option, current_version: Option, #[serde(default)] + current_files: Option, + #[serde(default)] + previous_files: Option, + #[serde(default)] operation: PersistedOperation, } @@ -190,13 +195,14 @@ impl UpdateOperation { pub(crate) fn complete( &mut self, - result: &Result<(), OperationError>, + result: &Result, ) -> Result<(), OperationError> { finish( &self.path, &self.operation_id, SystemOperationKind::Update, - result.as_ref().ok().map(|()| self.target.clone()), + result.as_ref().ok().map(|_| self.target.clone()), + result.as_ref().ok().cloned(), result.as_ref().err().map(ToString::to_string), )?; self.completed = true; @@ -212,6 +218,7 @@ impl Drop for UpdateOperation { &self.operation_id, SystemOperationKind::Update, None, + None, Some("更新任务已中断,请重新发起更新".to_owned()), ) { @@ -237,6 +244,7 @@ pub(crate) fn recover_interrupted(path: &Path, lock_path: &Path) -> Result<(), O &operation_id, kind.into(), None, + None, Some("更新任务已中断,请重新发起更新".to_owned()), )?; } @@ -281,6 +289,7 @@ pub(crate) fn finish( operation_id: &str, kind: SystemOperationKind, version: Option, + files: Option, error: Option, ) -> Result<(), OperationError> { let mut state = read_persisted(path)?; @@ -299,11 +308,14 @@ pub(crate) fn finish( SystemOperationKind::Update => { state.previous_version = state.current_version.take(); state.current_version = version.clone(); + state.previous_files = state.current_files.take(); + state.current_files = files; } SystemOperationKind::Rollback => { let current = state.current_version.take(); state.current_version = state.previous_version.take(); state.previous_version = current; + std::mem::swap(&mut state.current_files, &mut state.previous_files); } SystemOperationKind::Restart => {} } @@ -315,15 +327,12 @@ pub(crate) fn finish( pub(crate) fn read_status( path: &Path, - running_version: &str, + need_restart: bool, ) -> Result { let state = read_persisted(path)?; let operation = state.operation; Ok(SystemUpdateStatus { - need_restart: matches!(operation.status, PersistedStatus::Succeeded) - && state.current_version.as_deref().is_some_and(|version| { - version.trim_start_matches('v') != running_version.trim_start_matches('v') - }), + need_restart, previous_version: state.previous_version, current_version: state.current_version, operation: SystemOperationState { @@ -339,6 +348,52 @@ pub(crate) fn read_status( }) } +/// 调用方必须持有进程内锁及文件锁,避免将安装中间态当成手动部署。 +pub(crate) fn reconcile_installation( + config: &SystemUpdateConfig, + running: &ReleaseFiles, +) -> Result { + let path = &config.update_state_file; + let mut state = read_persisted(path)?; + let installed = ReleaseFiles::installed(config)?; + let need_restart = &installed != running; + if need_restart + && (state.current_files.as_ref() != Some(&installed) || state.current_version.is_none()) + { + return Err(conflict( + "运行期间安装文件被外部修改,请核对部署并重启服务后重试", + )); + } + let mut changed = false; + if !need_restart + && (state.current_files.as_ref() != Some(&installed) + || state.current_version.as_deref() != Some(config.version.as_str())) + { + // 保留操作历史,但外部部署的文件不能继续使用旧操作推断版本。 + state.current_version = Some(config.version.clone()); + state.current_files = Some(installed); + changed = true; + } + if state.previous_version.is_some() || state.previous_files.is_some() { + let backup_valid = state.previous_files.as_ref().is_some_and(|expected| { + ReleaseFiles::backup(config) + .as_ref() + .is_ok_and(|actual| actual == expected) + }); + if !backup_valid { + // 无法证明旧备份完整时撤销回滚资格,不删除用户的备份文件。 + state.previous_version = None; + state.previous_files = None; + changed = true; + } + } + if changed { + write_persisted(path, &state)?; + } + // 指纹证明磁盘仍是本进程安装的候选,回滚到旧版本也必须等待重启。 + read_status(path, need_restart) +} + pub(crate) fn default_temp_dir(state_file: &Path) -> PathBuf { state_file .parent() diff --git a/backend/crates/gateway-host/src/system_update/swap.rs b/backend/crates/gateway-host/src/system_update/swap.rs index 292ee8b8b..25fe9456b 100644 --- a/backend/crates/gateway-host/src/system_update/swap.rs +++ b/backend/crates/gateway-host/src/system_update/swap.rs @@ -393,7 +393,7 @@ fn copy_dir_all(from: &Path, to: &Path) -> io::Result<()> { Ok(()) } -fn backup_path_for(path: &Path) -> PathBuf { +pub(crate) fn backup_path_for(path: &Path) -> PathBuf { let mut backup = path.as_os_str().to_os_string(); backup.push(".backup"); PathBuf::from(backup) diff --git a/backend/crates/gateway-host/tests/system_update/channels.rs b/backend/crates/gateway-host/tests/system_update/channels.rs new file mode 100644 index 000000000..413cb76fc --- /dev/null +++ b/backend/crates/gateway-host/tests/system_update/channels.rs @@ -0,0 +1,292 @@ +use super::*; +use gateway_admin::model::system::SystemUpdateChannel::{Alpha, Beta, Experimental, Rc, Stable}; + +#[tokio::test] +async fn temporary_channel_should_filter_future_releases_without_changing_running_channel() { + let server = MockServer::start().await; + let fixture = Fixture::new(); + let releases: Vec<_> = ["1.1.0", "1.2.0-rc.1", "1.3.0-beta.1", "1.4.0-alpha.1", "1.9.0-exp.9", "2.0.0"] + .into_iter().map(|version| serde_json::json!({"tag_name": format!("v{version}"), "prerelease": version.contains('-')})).collect(); + Mock::given(method("GET")) + .and(path("/repos/owner/repository/releases")) + .respond_with(ResponseTemplate::new(200).set_body_json(releases)) + .mount(&server) + .await; + let service = fixture.service(&server); + for (channel, target) in [ + (Stable, "1.1.0"), + (Rc, "1.2.0-rc.1"), + (Beta, "1.3.0-beta.1"), + (Alpha, "1.4.0-alpha.1"), + (Stable, "1.1.0"), + ] { + let detail = service + .update_detail(false, Some(channel)) + .await + .expect("detail"); + assert_eq!(detail.latest_version, target); + assert_eq!(detail.policy.channel, channel); + assert_eq!( + detail.policy.available_channels, + vec![Stable, Rc, Beta, Alpha] + ); + assert!(detail.has_update); + let cached = service + .update_detail(false, Some(channel)) + .await + .expect("cached"); + assert!(cached.cached); + assert_eq!(cached.latest_version, target); + } + let version = service.version().await.expect("default check"); + assert_eq!(version.update_channel, "stable"); + assert_eq!(version.latest_version, "1.1.0"); + assert!(!fixture.state().exists(), "检查通道不能写入实例偏好"); + let requests = server.received_requests().await.expect("requests"); + assert_eq!(requests.len(), 5); + assert!( + requests + .iter() + .all(|request| request.url.path().ends_with("/releases")) + ); +} + +#[tokio::test] +async fn reopening_should_infer_running_channel_and_switching_to_stable_should_not_downgrade() { + let server = MockServer::start().await; + let fixture = Fixture::new(); + Mock::given(method("GET")) + .and(path("/repos/owner/repository/releases")) + .respond_with(release_response("1.0.0", Vec::new())) + .mount(&server) + .await; + let mut config = fixture.config(&format!("{}/repos", server.uri())); + config.version = "1.1.0-beta.1".into(); + let service = ProcessSystemOperations::new(CancellationToken::new(), config.clone()); + let temporary = service + .update_detail(true, Some(Stable)) + .await + .expect("view stable"); + assert_eq!(temporary.policy.channel, Stable); + assert!(!temporary.has_update); + assert_eq!(temporary.latest_version, "1.1.0-beta.1"); + assert_eq!( + service + .update_detail(false, None) + .await + .expect("reopened") + .policy + .channel, + Beta + ); + let restarted = ProcessSystemOperations::new(CancellationToken::new(), config.clone()); + assert_eq!( + restarted + .update_detail(false, None) + .await + .expect("restarted") + .policy + .channel, + Beta + ); + config.version = "1.1.0".into(); + let promoted = ProcessSystemOperations::new(CancellationToken::new(), config); + assert_eq!( + promoted + .update_detail(false, None) + .await + .expect("now stable") + .policy + .channel, + Stable + ); + assert!(!fixture.state().exists()); +} + +#[tokio::test] +async fn install_should_use_confirmed_channel_and_infer_new_default_only_after_restart() { + let server = MockServer::start().await; + let fixture = Fixture::new(); + fixture + .mount_release( + &server, + "1.1.0-beta.1", + ArchiveKind::Safe, + ChecksumKind::Valid, + ) + .await; + let service = fixture.service(&server); + let error = service + .perform_update( + Some("1.1.0-beta.1".into()), + Some(Stable), + Arc::new(AllowingUpdatePreflight), + ) + .await + .expect_err("target outside confirmed channel"); + assert_eq!(error.kind(), SystemOperationErrorKind::Conflict); + assert!( + server + .received_requests() + .await + .expect("requests") + .is_empty() + ); + service + .perform_update( + Some("1.1.0-beta.1".into()), + Some(Beta), + Arc::new(AllowingUpdatePreflight), + ) + .await + .expect("accept selected target"); + let status = wait_for_update(&service).await; + assert_eq!(status.operation.status, SystemOperationStatus::Succeeded); + assert!(status.need_restart); + assert_eq!( + service + .version() + .await + .expect("still running stable") + .update_channel, + "stable" + ); + assert!( + service + .perform_update( + Some("1.1.0-beta.1".into()), + Some(Alpha), + Arc::new(AllowingUpdatePreflight) + ) + .await + .is_err() + ); + let mut config = fixture.config(&format!("{}/repos", server.uri())); + config.version = "1.1.0-beta.1".into(); + let restarted = ProcessSystemOperations::new(CancellationToken::new(), config); + assert!( + !restarted + .update_status() + .await + .expect("restarted") + .need_restart + ); + assert_eq!( + restarted + .update_detail(false, None) + .await + .expect("new default") + .policy + .channel, + Beta + ); +} + +#[tokio::test] +async fn temporary_channels_should_keep_experimental_isolation() { + let server = MockServer::start().await; + let fixture = Fixture::new(); + let service = fixture.service(&server); + assert!( + service + .update_detail(true, Some(Experimental)) + .await + .is_err() + ); + let mut config = fixture.config(&format!("{}/repos", server.uri())); + config.version = "1.1.0-exp.1".into(); + config.build_type = "experimental".into(); + let experimental = ProcessSystemOperations::new(CancellationToken::new(), config); + for channel in [Stable, Rc, Beta, Alpha] { + assert!( + experimental + .update_detail(true, Some(channel)) + .await + .is_err() + ); + } + assert!( + server + .received_requests() + .await + .expect("requests") + .is_empty() + ); +} + +#[tokio::test] +async fn slower_check_should_not_overwrite_newer_cached_candidate() { + let server = MockServer::start().await; + let fixture = Fixture::new(); + Mock::given(method("GET")) + .and(path("/repos/owner/repository/releases")) + .respond_with(release_response("1.1.0", Vec::new()).set_delay(Duration::from_millis(150))) + .up_to_n_times(1) + .with_priority(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/repos/owner/repository/releases")) + .respond_with(release_response("1.2.0", Vec::new())) + .mount(&server) + .await; + let service = fixture.service(&server); + let first_service = service.clone(); + let first = tokio::spawn(async move { first_service.update_detail(true, None).await }); + tokio::time::timeout(Duration::from_secs(1), async { + while server + .received_requests() + .await + .expect("requests") + .is_empty() + { + tokio::task::yield_now().await; + } + }) + .await + .expect("first request sent"); + assert_eq!( + service + .update_detail(true, None) + .await + .expect("new check") + .latest_version, + "1.2.0" + ); + assert_eq!( + first + .await + .expect("join") + .expect("old check") + .latest_version, + "1.1.0" + ); + let cached = service.update_detail(false, None).await.expect("cache"); + assert!(cached.cached); + assert_eq!(cached.latest_version, "1.2.0"); +} + +#[tokio::test] +async fn inferred_prerelease_policy_should_follow_future_minor_and_patch_releases() { + for (current, target) in [ + ("3.12.0-alpha.1", "3.13.0-alpha.2"), + ("3.12.0-beta.1", "3.12.1-rc.1"), + ("3.12.0-rc.1", "3.13.0"), + ] { + let server = MockServer::start().await; + let fixture = Fixture::new(); + fixture + .mount_release(&server, target, ArchiveKind::Safe, ChecksumKind::Valid) + .await; + let mut config = fixture.config(&format!("{}/repos", server.uri())); + config.version = current.to_owned(); + let service = ProcessSystemOperations::new(CancellationToken::new(), config); + let detail = service.update_detail(true, None).await.expect("check"); + assert!(detail.has_update); + assert_eq!(detail.latest_version, target); + assert_eq!( + complete_update(&service, target).await.operation.status, + SystemOperationStatus::Succeeded + ); + } +} diff --git a/backend/crates/gateway-host/tests/system_update/installation.rs b/backend/crates/gateway-host/tests/system_update/installation.rs new file mode 100644 index 000000000..f6f151d0c --- /dev/null +++ b/backend/crates/gateway-host/tests/system_update/installation.rs @@ -0,0 +1,148 @@ +use super::*; + +#[tokio::test] +async fn manual_image_replacement_should_clear_pending_state_and_invalid_backup() { + let server = MockServer::start().await; + let fixture = Fixture::new(); + fixture + .mount_release( + &server, + TARGET_VERSION, + ArchiveKind::Safe, + ChecksumKind::Valid, + ) + .await; + let service = fixture.service(&server); + assert!(complete_update(&service, TARGET_VERSION).await.need_restart); + // 模拟重建镜像:数据卷保留,应用文件和容器内备份被重新替换。 + fixture.write_executable("manually-installed-binary"); + fs::remove_file(fixture.root.path().join("codex-proxy-rs.backup")) + .expect("remove image backup"); + let mut config = fixture.config(&format!("{}/repos", server.uri())); + config.version = "1.1.0".into(); + config.deployment_mode = "docker".into(); + let redeployed = ProcessSystemOperations::new(CancellationToken::new(), config); + let status = redeployed.update_status().await.expect("reconciled"); + assert!(!status.need_restart); + assert_eq!(status.current_version.as_deref(), Some("1.1.0")); + assert!(status.previous_version.is_none()); + assert_eq!( + status.operation.target_version.as_deref(), + Some(TARGET_VERSION) + ); + assert!( + redeployed + .update_detail(true, None) + .await + .expect("check") + .has_update + ); + assert_eq!( + complete_update(&redeployed, TARGET_VERSION) + .await + .operation + .status, + SystemOperationStatus::Succeeded + ); +} + +#[tokio::test] +async fn rollback_after_restart_should_require_restart_even_for_a_lower_version() { + let server = MockServer::start().await; + let fixture = Fixture::new(); + fixture + .mount_release( + &server, + TARGET_VERSION, + ArchiveKind::Safe, + ChecksumKind::Valid, + ) + .await; + let service = fixture.service(&server); + assert!(complete_update(&service, TARGET_VERSION).await.need_restart); + let mut config = fixture.config(&format!("{}/repos", server.uri())); + config.version = TARGET_VERSION.into(); + let restarted = ProcessSystemOperations::new(CancellationToken::new(), config); + let status = restarted.update_status().await.expect("new process"); + assert!(!status.need_restart); + assert_eq!(status.previous_version.as_deref(), Some("1.0.0")); + restarted + .rollback(Arc::new(AllowingUpdatePreflight)) + .await + .expect("rollback"); + let status = restarted.update_status().await.expect("pending rollback"); + assert!(status.need_restart); + assert_eq!(status.current_version.as_deref(), Some("1.0.0")); + assert!( + !fixture + .service(&server) + .update_status() + .await + .expect("rollback restarted") + .need_restart + ); +} + +#[tokio::test] +async fn external_change_during_runtime_should_not_be_reported_as_a_successful_installation() { + let server = MockServer::start().await; + let fixture = Fixture::new(); + let service = fixture.service(&server); + service.update_status().await.expect("baseline"); + fs::write(fixture.web().join("index.html"), "unexpected-web").expect("external replacement"); + assert_eq!( + service + .update_status() + .await + .expect_err("mixed deployment") + .kind(), + SystemOperationErrorKind::Conflict + ); + assert!( + service + .perform_test_update(Some(TARGET_VERSION.into())) + .await + .is_err() + ); + assert_eq!( + fs::read(fixture.executable()).expect("binary"), + b"old-binary" + ); +} + +#[tokio::test] +async fn pending_update_should_revoke_rollback_when_backup_content_changes() { + let server = MockServer::start().await; + let fixture = Fixture::new(); + fixture + .mount_release( + &server, + TARGET_VERSION, + ArchiveKind::Safe, + ChecksumKind::Valid, + ) + .await; + let service = fixture.service(&server); + assert!(complete_update(&service, TARGET_VERSION).await.need_restart); + fs::write( + fixture.root.path().join("codex-proxy-rs.backup"), + "unrelated-backup", + ) + .expect("changed backup"); + let status = service + .update_status() + .await + .expect("pending with invalid backup"); + assert!(status.need_restart); + assert!(status.previous_version.is_none()); + assert!( + service + .rollback(Arc::new(AllowingUpdatePreflight)) + .await + .is_err() + ); + assert_eq!( + fs::read(fixture.executable()).expect("binary"), + b"new-binary" + ); +} diff --git a/backend/crates/gateway-host/tests/system_update/mod.rs b/backend/crates/gateway-host/tests/system_update/mod.rs index 88f246564..b5ff40259 100644 --- a/backend/crates/gateway-host/tests/system_update/mod.rs +++ b/backend/crates/gateway-host/tests/system_update/mod.rs @@ -1,3 +1,6 @@ +mod channels; +mod installation; + use std::fs; use std::path::PathBuf; use std::sync::{ @@ -61,7 +64,8 @@ impl TestSystemUpdate for ProcessSystemOperations { &self, target: Option, ) -> Result { - SystemOperations::perform_update(self, target, Arc::new(AllowingUpdatePreflight)).await + SystemOperations::perform_update(self, target, None, Arc::new(AllowingUpdatePreflight)) + .await } } @@ -349,6 +353,7 @@ async fn update_should_restore_all_files_when_plugin_revision_changes_during_swa SystemOperations::perform_update( &service, Some(TARGET_VERSION.to_owned()), + None, Arc::new(ChangingRevisionPreflight { confirmations: AtomicUsize::new(0), }), @@ -496,9 +501,15 @@ async fn update_detail_should_report_refresh_failure_and_clear_previous_update() let fixture = Fixture::new(); fixture.mount_release_once(&server, TARGET_VERSION).await; let service = fixture.service(&server); - service.update_detail(true).await.expect("prime cache"); + service + .update_detail(true, None) + .await + .expect("prime cache"); - let detail = service.update_detail(true).await.expect("failure detail"); + let detail = service + .update_detail(true, None) + .await + .expect("failure detail"); assert!(!detail.has_update); assert!(detail.warning.is_some()); assert!(!detail.cached); @@ -541,7 +552,7 @@ async fn update_detail_should_offer_latest_non_draft_release_in_current_channel( config.version = "1.1.0-beta.1".to_owned(); let service = ProcessSystemOperations::new(CancellationToken::new(), config); - let detail = service.update_detail(true).await.expect("detail"); + let detail = service.update_detail(true, None).await.expect("detail"); assert_eq!(detail.latest_version, "1.1.0-beta.2"); assert!(detail.has_update); } @@ -554,7 +565,10 @@ async fn update_detail_should_reject_untrusted_github_api_base() { fixture.config("https://api.github.example/repos"), ); - let detail = service.update_detail(true).await.expect("safe rejection"); + let detail = service + .update_detail(true, None) + .await + .expect("safe rejection"); assert!(!detail.update_supported); assert!(detail.warning.is_some() || detail.unsupported_reason.is_some()); } @@ -567,7 +581,7 @@ async fn update_detail_should_withhold_updates_for_source_builds() { config.build_type = "source".to_owned(); let service = ProcessSystemOperations::new(CancellationToken::new(), config); - let detail = service.update_detail(true).await.expect("detail"); + let detail = service.update_detail(true, None).await.expect("detail"); assert_eq!(detail.latest_version, "1.0.0"); assert!(!detail.update_supported); assert!(detail.unsupported_reason.is_some()); @@ -610,7 +624,7 @@ async fn experimental_build_should_ignore_newer_stable_and_other_experimental_re config.build_type = "experimental".to_owned(); let service = ProcessSystemOperations::new(CancellationToken::new(), config); - let detail = service.update_detail(true).await.expect("detail"); + let detail = service.update_detail(true, None).await.expect("detail"); assert_eq!(detail.latest_version, "3.10.0-exp.2"); assert!(!detail.has_update); assert!(detail.update_supported); @@ -620,7 +634,10 @@ async fn experimental_build_should_ignore_newer_stable_and_other_experimental_re let version = service.version().await.expect("version"); assert!(!version.has_update); assert_eq!(version.update_channel, "exp"); - let cached = service.update_detail(false).await.expect("cached detail"); + let cached = service + .update_detail(false, None) + .await + .expect("cached detail"); assert!(cached.cached); assert!(!cached.has_update); } @@ -657,7 +674,7 @@ async fn experimental_update_should_find_highest_same_channel_version_across_pag config.build_type = "experimental".to_owned(); let service = ProcessSystemOperations::new(CancellationToken::new(), config); - let detail = service.update_detail(true).await.expect("detail"); + let detail = service.update_detail(true, None).await.expect("detail"); assert_eq!(detail.latest_version, "3.10.0-exp.10"); assert_eq!(detail.notes.as_deref(), Some("exp.10 notes")); assert!(detail.has_update); @@ -716,8 +733,6 @@ async fn update_should_reject_cross_channel_targets_before_fetching_or_replacing ("experimental", "3.10.0-exp.2", "3.10.1-exp.3"), ("experimental", "3.10.0-exp.2", "3.10.0-exp.1"), ("release", "3.10.0-beta.2", "3.10.0-alpha.3"), - ("release", "3.10.0-rc.2", "3.11.0-rc.3"), - ("release", "3.10.0-rc.2", "3.11.0"), ("release", "3.10.0", "3.10.0+new"), ("release", "3.10.0", "3.9.0"), ("experimental", "3.10.0-exp.2", "3.11.0-beta.1"), @@ -737,7 +752,7 @@ async fn update_should_reject_cross_channel_targets_before_fetching_or_replacing .await .expect_err("cross-channel update"); assert_eq!(error.kind(), SystemOperationErrorKind::Conflict); - assert!(error.to_string().contains("跨通道")); + assert!(error.to_string().contains("所选通道")); assert!( server .received_requests() @@ -784,7 +799,7 @@ async fn update_checks_should_follow_stage_promotion_and_experimental_isolation( config.version = current.to_owned(); let service = ProcessSystemOperations::new(CancellationToken::new(), config); - let detail = service.update_detail(true).await.expect("detail"); + let detail = service.update_detail(true, None).await.expect("detail"); assert_eq!(detail.has_update, allowed, "{current} -> {target}"); assert_eq!( detail.latest_version, @@ -833,7 +848,10 @@ async fn update_detail_should_keep_current_release_notes_without_allowing_reinst } let service = ProcessSystemOperations::new(CancellationToken::new(), config); - let detail = service.update_detail(true).await.expect("current release"); + let detail = service + .update_detail(true, None) + .await + .expect("current release"); assert_eq!(detail.latest_version, current); assert!(!detail.has_update); assert!(detail.update_supported); @@ -842,7 +860,10 @@ async fn update_detail_should_keep_current_release_notes_without_allowing_reinst Some("## 当前版本\n\n- 修复更新状态") ); assert_eq!(detail.release_url.as_deref(), Some(release_url.as_str())); - let cached = service.update_detail(false).await.expect("cached release"); + let cached = service + .update_detail(false, None) + .await + .expect("cached release"); assert!(cached.cached); assert_eq!(cached.notes, detail.notes); assert_eq!(cached.release_url, detail.release_url); @@ -886,7 +907,7 @@ async fn update_detail_should_prefer_available_update_notes_over_current_release let detail = fixture .service(&server) - .update_detail(true) + .update_detail(true, None) .await .expect("detail"); assert!(detail.has_update); @@ -923,7 +944,7 @@ async fn update_detail_should_find_current_release_notes_across_pages() { let detail = fixture .service(&server) - .update_detail(true) + .update_detail(true, None) .await .expect("detail"); assert!(!detail.has_update); @@ -950,7 +971,7 @@ async fn update_detail_should_not_use_draft_or_mislabeled_current_release_notes( config.version = current.to_owned(); let service = ProcessSystemOperations::new(CancellationToken::new(), config); - let detail = service.update_detail(true).await.expect("detail"); + let detail = service.update_detail(true, None).await.expect("detail"); assert!(!detail.has_update); assert_eq!(detail.latest_version, current); assert!(detail.notes.is_none()); @@ -959,11 +980,8 @@ async fn update_detail_should_not_use_draft_or_mislabeled_current_release_notes( } #[tokio::test] -async fn update_checks_should_ignore_other_cycles_downgrades_and_build_metadata() { +async fn update_checks_should_ignore_downgrades_unknown_stages_and_build_metadata() { for (current, target) in [ - ("3.12.0-alpha.1", "3.13.0-alpha.2"), - ("3.12.0-beta.1", "3.12.1-rc.1"), - ("3.12.0-rc.1", "3.13.0"), ("3.12.0-beta.2", "3.12.0-beta.1"), ("3.12.0", "3.12.0+new-build"), ("3.12.0-beta.1+aaa", "3.12.0-beta.1+zzz"), @@ -979,7 +997,7 @@ async fn update_checks_should_ignore_other_cycles_downgrades_and_build_metadata( let mut config = fixture.config(&format!("{}/repos", server.uri())); config.version = current.to_owned(); let service = ProcessSystemOperations::new(CancellationToken::new(), config); - let detail = service.update_detail(true).await.expect("detail"); + let detail = service.update_detail(true, None).await.expect("detail"); assert!(!detail.has_update, "{current} -> {target}"); assert_eq!(detail.latest_version, current); assert!(detail.notes.is_none()); @@ -1003,7 +1021,7 @@ async fn update_checks_should_fail_closed_for_unknown_current_channels() { config.version = current.to_owned(); config.build_type = build_type.to_owned(); let service = ProcessSystemOperations::new(CancellationToken::new(), config); - let detail = service.update_detail(true).await.expect("detail"); + let detail = service.update_detail(true, None).await.expect("detail"); assert!(!detail.has_update); assert!(!detail.update_supported); assert!(detail.unsupported_reason.is_some()); @@ -1036,7 +1054,7 @@ async fn stable_update_should_select_allowed_version_below_newer_major_releases( let mut config = fixture.config(&format!("{}/repos", server.uri())); config.version = "3.11.0".to_owned(); let service = ProcessSystemOperations::new(CancellationToken::new(), config); - let detail = service.update_detail(true).await.expect("detail"); + let detail = service.update_detail(true, None).await.expect("detail"); assert!(detail.has_update); assert_eq!(detail.latest_version, "3.11.10"); assert_eq!(detail.notes.as_deref(), Some("stable notes")); @@ -1060,7 +1078,7 @@ async fn prerelease_updates_should_install_later_stages_and_stable_release() { let service = ProcessSystemOperations::new(CancellationToken::new(), config); assert!( service - .update_detail(true) + .update_detail(true, None) .await .expect("detail") .has_update @@ -1095,9 +1113,12 @@ async fn update_detail_should_use_cached_release_when_not_refreshed() { let fixture = Fixture::new(); fixture.mount_release_once(&server, TARGET_VERSION).await; let service = fixture.service(&server); - service.update_detail(true).await.expect("prime cache"); + service + .update_detail(true, None) + .await + .expect("prime cache"); - assert!(service.update_detail(false).await.is_ok()); + assert!(service.update_detail(false, None).await.is_ok()); } #[tokio::test] @@ -1110,7 +1131,7 @@ async fn update_detail_should_withhold_cross_major_release() { let detail = fixture .service(&server) - .update_detail(true) + .update_detail(true, None) .await .expect("detail"); assert_eq!(detail.latest_version, "1.0.0"); @@ -1569,27 +1590,19 @@ async fn update_should_restore_web_assets_when_binary_backup_fails() { } #[tokio::test] -async fn update_status_should_read_local_update_state() { +async fn update_status_should_reconcile_manual_deployment_without_reusing_legacy_success() { let fixture = Fixture::new(); - fs::write( - fixture.state(), - r#"{"previousVersion":"1.0.0","currentVersion":"2.0.0","operation":{"operationId":"x","kind":"update","status":"succeeded","targetVersion":"2.0.0","message":"done","error":null,"startedAt":"2026-07-19T00:00:00Z","finishedAt":"2026-07-19T00:01:00Z"}}"#, - ) - .expect("state"); - let service = ProcessSystemOperations::new( - CancellationToken::new(), - fixture.config("https://api.github.com/repos"), - ); - - assert_eq!( - service - .update_status() - .await - .expect("status") - .previous_version - .as_deref(), - Some("1.0.0") - ); + fs::write(fixture.state(), r#"{"previousVersion":"3.14.0","currentVersion":"3.14.1","operation":{"operationId":"legacy","kind":"update","status":"succeeded","targetVersion":"3.14.1"}}"#).expect("legacy state"); + let mut config = fixture.config("https://api.github.com/repos"); + config.version = "3.15.0".to_owned(); + config.deployment_mode = "docker".to_owned(); + let service = ProcessSystemOperations::new(CancellationToken::new(), config); + let status = service.update_status().await.expect("status"); + assert!(!status.need_restart); + assert_eq!(status.current_version.as_deref(), Some("3.15.0")); + assert!(status.previous_version.is_none()); + assert_eq!(status.operation.target_version.as_deref(), Some("3.14.1")); + assert_eq!(status.operation.status, SystemOperationStatus::Succeeded); } #[tokio::test] diff --git a/backend/crates/gateway-plugin/runtime/tests/support/environment.rs b/backend/crates/gateway-plugin/runtime/tests/support/environment.rs index 5b145f153..77a0d583a 100644 --- a/backend/crates/gateway-plugin/runtime/tests/support/environment.rs +++ b/backend/crates/gateway-plugin/runtime/tests/support/environment.rs @@ -732,7 +732,11 @@ impl SystemOperations for UnusedAdminRuntime { Err(unused_system()) } - async fn update_detail(&self, _: bool) -> Result { + async fn update_detail( + &self, + _: bool, + _: Option, + ) -> Result { Err(unused_system()) } @@ -743,6 +747,7 @@ impl SystemOperations for UnusedAdminRuntime { async fn perform_update( &self, _: Option, + _: Option, _: Arc, ) -> Result { Err(unused_system()) diff --git a/deploy/README.md b/deploy/README.md index 588f53c73..45f2a34d4 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -467,19 +467,24 @@ docker compose -f deploy/compose.yaml build codex-proxy-rs ### 版本命名与升级规则 -发行通道由当前版本确定,不提供通道切换配置。预发行编号 `N` 从 1 开始递增。 +管理端「系统更新」可临时选择通道检查更新,不保存选择,不会自动下载或安装。 +每次打开弹窗按当前运行版本匹配通道:正式版为 Stable,预发行按版本后缀匹配。 +升级并重启后按新运行版本匹配;侧栏更新提示始终使用当前运行通道。预发行编号 `N` 从 1 开始递增。 -| 类型 | 命名示例 | 含义 | 允许在线更新到 | -| --- | --- | --- | --- | -| 正式版 | `3.12.0` | 稳定发布 | 同一大版本内更新的正式版 | -| alpha | `3.12.0-alpha.1` | 下一正式版本的早期开发阶段 | 同一目标版本的更新 alpha、beta、rc、正式版 | -| beta | `3.12.0-beta.1` | 下一正式版本的功能测试阶段 | 同一目标版本的更新 beta、rc、正式版 | -| rc | `3.12.0-rc.1` | 准备转正的发布候选版本 | 同一目标版本的更新 rc、正式版 | -| exp | `3.10.0-exp.1` | 临时功能实验,可能合入正式版或结束维护 | 同一轮实验中编号更高的 exp | +| 通道 | 命名示例 | 接收的版本 | +| --- | --- | --- | +| Stable | `3.12.0` | 正式版 | +| RC | `3.12.0-rc.1` | RC、正式版 | +| Beta | `3.12.0-beta.1` | Beta、RC、正式版 | +| Alpha | `3.12.0-alpha.1` | Alpha、Beta、RC、正式版 | +| Exp | `3.10.0-exp.1` | 同一轮实验中编号更高的 exp,仅实验实例可选 | -“同一目标版本”要求 `X.Y.Z` 相同。alpha → beta → rc → 正式版可以跳过中间阶段,不能倒退; -转正后按正式版规则更新,不自动进入下一轮预发行。同一 `X.Y.Z-exp.N` 系列只用于同一轮实验, -不能混用不同实验分支;变更基线即进入另一实验线,需手动迁移。exp 不自动进入正式版、alpha、beta 或 rc。 +普通通道在同一大版本内持续接收更高版本,包括后续小版本和补丁版本的预发行。 +切回 Stable 不会降级:例如运行 `3.16.0-beta.2` 时,不能安装 `3.15.1`,需要等待 `3.16.0` 或更高正式版。 +下载、安装或待重启期间不能切换通道。 + +同一 `X.Y.Z-exp.N` 系列只用于同一轮实验,不能混用不同实验分支;变更基线即进入另一实验线,需手动迁移。 +exp 不进入正式版、alpha、beta 或 rc,普通实例也不能选择 Exp。 从 exp 切换正式版时需先备份,在目标版本的独立数据库中迁移业务数据,不能直接复用或整库还原实验数据库。 版本大小遵循 [SemVer](https://semver.org/),构建元数据 `+...` 不参与比较。未知预发行标识不提供在线更新。 @@ -494,6 +499,13 @@ alpha、beta、rc、exp 在 GitHub 标记为 Pre-release,不覆盖 GitHub Late ### 管理端在线更新 官方构建按上述规则在线更新;源码等非官方构建返回不支持原因,不提供可用更新。 +点击「下载并更新」后在原有更新日志中显示下载、校验与安装过程,完成后显示「待重启」及待生效版本,重启后才使用新程序。 +「当前版本」始终表示正在运行的版本,「通道最新」表示所选通道的检查结果。 + +手动更换镜像或程序后,更新器会核对安装文件并校准历史状态,不以旧成功记录推断需要重启。 +运行期间检测到外部文件变动或文件缺失时会报告异常,需要核对完整发行包并重启服务后再检查。 +回滚入口只接受与记录指纹一致的完整备份;旧格式记录没有指纹,或备份被替换、丢失时,不再提供在线回滚, +但不会删除备份文件。此时需要按人工部署流程恢复已经核实的发行包。 Compose 提供以下在线更新运行参数: diff --git a/docs/api.md b/docs/api.md index 02f59faf0..8ce172119 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1558,15 +1558,20 @@ Key 已删除或未关联时为 `null`,不影响记录返回,不包含密钥 | 方法 | 路由 | 主要 query/body | 说明 | | --- | --- | --- | --- | | `GET` | `/api/admin/system/version` | 无 | 当前构建、部署模式和可用更新 | -| `GET` | `/api/admin/system/update/detail` | `refresh=true|false` | 读取或强制刷新 Release 详情 | +| `GET` | `/api/admin/system/update/detail` | `refresh=true|false`、`channel?` | 按临时通道读取或刷新 Release 详情 | | `GET` | `/api/admin/system/update/events` | 无 | SSE 更新事件流 | -| `POST` | `/api/admin/system/update` | `{ targetVersion }` | 受理后台在线更新,返回 `202` | +| `POST` | `/api/admin/system/update` | `{ targetVersion, channel? }` | 受理后台在线更新,返回 `202` | | `GET` | `/api/admin/system/update/status` | 无 | 查询当前更新或回滚状态 | | `POST` | `/api/admin/system/rollback` | 无 | 回滚到保留的上一版本 | | `POST` | `/api/admin/system/restart` | 无 | 请求进程重启 | 在线更新遵循[版本命名与升级规则](../deploy/README.md#版本命名与升级规则)。版本接口的 `updateChannel` 由当前版本推导,取值为 `stable`、`alpha`、`beta`、`rc`、`exp`,无法识别时为 `unknown`。 +详情接口的可选 `channel` 仅作用于本次查询,不保存实例偏好;省略时按当前运行版本推导。 +响应的 `policy` 包含本次 `channel` 与 `availableChannels`,普通实例可选 `stable`、`rc`、`beta`、`alpha`, +实验实例仅允许 `exp`。不可用通道返回 `40901`,未知枚举值返回参数错误。 +执行时应同时发送页面确认的 `channel` 与 `targetVersion`,服务端冻结该通道并重新复核远端目标; +旧客户端省略通道时按运行版本推导。版本摘要接口始终检查运行通道,不受临时查询影响。 检查与执行使用同一规则,禁止的通道转换、跨实验线、跨大版本、降级或同版本重装均以 `40901` 拒绝。 `hasUpdate=true` 仅表示当前构建支持在线更新,且存在允许的更高版本;`latestVersion`、`releaseUrl` 和 `notes` 对应这个候选。没有可升级候选时,`hasUpdate=false`、`latestVersion` 为当前版本, @@ -1574,7 +1579,7 @@ Key 已删除或未关联时为 `null`,不影响记录返回,不包含密钥 当前构建不支持在线更新时不查询 Release,`hasUpdate=false`、`latestVersion` 为当前版本, `releaseUrl` 和 `notes` 为空,不支持原因通过 `updateSupported=false`、`unsupportedReason` 返回。 强制检查失败时通过 `warning` 返回错误,`hasUpdate=false`,不以旧缓存或“没有更新”掩盖失败。 -普通查询可复用 20 分钟内的结果。下载时仍会校验目标资产、校验和及归档。 +普通查询可复用 20 分钟内的结果,缓存按通道隔离,较慢的旧检查不能覆盖新检查结果。下载时仍会校验目标资产、校验和及归档。 更新 POST 在本地校验目标版本并持久化任务后返回 `202`,数据包含 `operationId`、`targetVersion`、 `deploymentMode` 和 `message`,只表示已受理。Release 查询、远端目标复核、下载、校验及文件替换在后台 @@ -1587,7 +1592,10 @@ Key 已删除或未关联时为 `null`,不影响记录返回,不包含密钥 版本或增加权限。 状态响应的 `currentVersion` 表示已安装文件的版本,运行中的版本仍以 `/version` 为准。 -`needRestart=true` 表示成功安装的版本尚未在当前进程生效,此时应调用重启接口,不能重复发起更新。 +`needRestart=true` 表示已验证的安装文件尚未在当前进程生效,此时应调用重启接口,不能重复发起更新或切换通道。 +最近一次 `operation` 仅表示操作历史,成功记录不等于待重启,也不改变远端 `hasUpdate`。 +手动部署后按实际文件校准 `currentVersion`;无法核实的回滚备份不再返回 `previousVersion`。 +运行期间的外部文件变动或不完整安装返回错误,不伪装成安装成功。 Host 关闭或任务取消会记录失败终态;状态查询会收敛无执行锁的遗留 `running`。 异常退出留下的锁仍遵循 30 分钟过期规则,未过期前不会抢占其他进程的操作。 实例升级和仓库发版见 [部署文档](../deploy/README.md#镜像升级与源码构建)。 diff --git a/docs/architecture.md b/docs/architecture.md index 0a4984340..40d11cecf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -709,7 +709,12 @@ worker 复用连接测试探针执行真实上游调用,并按冻结代次处 还要通过全部启用插件的静态兼容检查,文件交换前后复核同一插件配置版本,失败或取消时恢复二进制、Web 与 官方插件目录。 Host 在受理时持久化任务并转交后台执行,任务持有操作锁与终态写入责任,不依赖 HTTP 请求的生命周期。 - 状态文件是更新结果的权威来源,SSE 终态在状态落盘后发送;Host 关闭与任务析构都必须收敛状态。 + 状态文件记录操作结果,SSE 终态在状态落盘后发送;Host 关闭与任务析构都必须收敛状态。 + 安装状态由 Host 对照启动时的发行文件指纹、磁盘文件与安装记录校准,历史成功记录本身不构成待重启事实。 + 二进制、Web 资源、官方插件目录及回滚备份须整体匹配,无法核实的备份不参与回滚。 + 通道是单次查询或安装的参数,不持久化偏好;默认按运行版本推导,检查和执行共用候选策略, + 缓存隔离通道并拒绝过期写回,安装在受理时冻结确认的通道与目标; + Admin/API 只转发策略事实,前端分别展示运行版本、通道候选与待生效版本。 PostgreSQL 备份恢复属于人工维护操作。当前没有部署级维护模式开关,需要先停止应用, 离线处理快照中的非终态任务、计划游标和到期清理条件,再重新验证对象存储并恢复计划。 diff --git a/frontend/src/api/modules/system.ts b/frontend/src/api/modules/system.ts index 8bfb444be..2986caf94 100644 --- a/frontend/src/api/modules/system.ts +++ b/frontend/src/api/modules/system.ts @@ -1,6 +1,13 @@ import type { RequestOptions } from '../request' import request from '../request' +export type SystemUpdateChannel = 'stable' | 'rc' | 'beta' | 'alpha' | 'exp' + +export interface SystemUpdatePolicy { + channel: SystemUpdateChannel + availableChannels: SystemUpdateChannel[] +} + export interface SystemVersion { version: string gitSha: string @@ -15,6 +22,7 @@ export interface SystemVersion { } export interface SystemUpdateDetail { + policy: SystemUpdatePolicy currentVersion: string latestVersion: string hasUpdate: boolean @@ -68,10 +76,12 @@ export function getSystemVersion(options: RequestOptions = {}) { interface SystemUpdateDetailQuery { refresh?: boolean + channel?: SystemUpdateChannel } interface SystemUpdateTarget { - targetVersion?: string + targetVersion: string + channel: SystemUpdateChannel } export function getSystemUpdateDetail(data: SystemUpdateDetailQuery) { diff --git a/frontend/src/layout/components/SystemUpdateModal/index.vue b/frontend/src/layout/components/SystemUpdateModal/index.vue index d91db9524..76f0cd03b 100644 --- a/frontend/src/layout/components/SystemUpdateModal/index.vue +++ b/frontend/src/layout/components/SystemUpdateModal/index.vue @@ -1,10 +1,12 @@