mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
feat(system): add update channels and reconcile installation state
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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`。
|
||||
发布预发行前记录这两个稳定入口,发布后回读确认未改变。
|
||||
|
||||
@@ -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<SystemUpdateChannel>,
|
||||
}
|
||||
|
||||
/// 当前运行版本及更新检查摘要。
|
||||
///
|
||||
@@ -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,
|
||||
|
||||
@@ -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<SystemUpdateChannel>,
|
||||
) -> Result<SystemUpdateDetail, SystemOperationError>;
|
||||
|
||||
fn update_events(&self) -> SystemUpdateEventStream;
|
||||
@@ -96,6 +97,7 @@ pub trait SystemOperations: Send + Sync {
|
||||
async fn perform_update(
|
||||
&self,
|
||||
target_version: Option<String>,
|
||||
channel: Option<SystemUpdateChannel>,
|
||||
preflight: Arc<dyn SystemUpdatePreflight>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError>;
|
||||
|
||||
|
||||
@@ -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<SystemVersion, AdminError>;
|
||||
async fn update_detail(&self, refresh: bool) -> Result<SystemUpdateDetail, AdminError>;
|
||||
async fn update_detail(
|
||||
&self,
|
||||
refresh: bool,
|
||||
channel: Option<SystemUpdateChannel>,
|
||||
) -> Result<SystemUpdateDetail, AdminError>;
|
||||
fn update_events(&self) -> SystemUpdateEventStream;
|
||||
async fn perform_update(
|
||||
&self,
|
||||
target_version: Option<String>,
|
||||
channel: Option<SystemUpdateChannel>,
|
||||
) -> Result<SystemOperationAccepted, AdminError>;
|
||||
async fn update_status(&self) -> Result<SystemUpdateStatus, AdminError>;
|
||||
async fn rollback(&self) -> Result<SystemOperationAccepted, AdminError>;
|
||||
@@ -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<SystemUpdateDetail, AdminError> {
|
||||
async fn update_detail(
|
||||
&self,
|
||||
refresh: bool,
|
||||
channel: Option<SystemUpdateChannel>,
|
||||
) -> Result<SystemUpdateDetail, AdminError> {
|
||||
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<String>,
|
||||
channel: Option<SystemUpdateChannel>,
|
||||
) -> Result<SystemOperationAccepted, AdminError> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1035,7 +1035,11 @@ impl SystemOperations for UnavailableSystem {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
|
||||
async fn update_detail(&self, _: bool) -> Result<SystemUpdateDetail, SystemOperationError> {
|
||||
async fn update_detail(
|
||||
&self,
|
||||
_: bool,
|
||||
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
|
||||
) -> Result<SystemUpdateDetail, SystemOperationError> {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
|
||||
@@ -1046,6 +1050,7 @@ impl SystemOperations for UnavailableSystem {
|
||||
async fn perform_update(
|
||||
&self,
|
||||
_: Option<String>,
|
||||
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
|
||||
_: Arc<dyn gateway_admin::ports::system::SystemUpdatePreflight>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
Err(unavailable_system())
|
||||
|
||||
@@ -225,7 +225,11 @@ impl SystemOperations for PreflightingSystem {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn update_detail(&self, _: bool) -> Result<SystemUpdateDetail, SystemOperationError> {
|
||||
async fn update_detail(
|
||||
&self,
|
||||
_: bool,
|
||||
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
|
||||
) -> Result<SystemUpdateDetail, SystemOperationError> {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
@@ -236,6 +240,7 @@ impl SystemOperations for PreflightingSystem {
|
||||
async fn perform_update(
|
||||
&self,
|
||||
_: Option<String>,
|
||||
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
|
||||
preflight: Arc<dyn SystemUpdatePreflight>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
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()
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ use gateway_admin::{
|
||||
#[derive(Default)]
|
||||
struct RecordingSystemOperations {
|
||||
target: Mutex<Option<Option<String>>>,
|
||||
channel: Mutex<Option<gateway_admin::model::system::SystemUpdateChannel>>,
|
||||
}
|
||||
|
||||
#[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<SystemUpdateDetail, SystemOperationError> {
|
||||
async fn update_detail(
|
||||
&self,
|
||||
_: bool,
|
||||
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
|
||||
) -> Result<SystemUpdateDetail, SystemOperationError> {
|
||||
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<String>,
|
||||
channel: Option<gateway_admin::model::system::SystemUpdateChannel>,
|
||||
_: std::sync::Arc<dyn gateway_admin::ports::system::SystemUpdatePreflight>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
*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));
|
||||
}
|
||||
|
||||
@@ -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<bool>,
|
||||
channel: Option<SystemUpdateChannel>,
|
||||
}
|
||||
|
||||
impl UpdateDetailQuery {
|
||||
#[must_use]
|
||||
pub const fn channel(&self) -> Option<SystemUpdateChannel> {
|
||||
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<SystemUpdateChannel>,
|
||||
}
|
||||
|
||||
impl UpdateRequest {
|
||||
@@ -95,6 +102,7 @@ impl From<SystemVersion> 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<SystemUpdateDetail> 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<S>(
|
||||
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 {
|
||||
|
||||
@@ -1456,7 +1456,11 @@ impl SystemOperations for UnusedSystem {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
|
||||
async fn update_detail(&self, _: bool) -> Result<SystemUpdateDetail, SystemOperationError> {
|
||||
async fn update_detail(
|
||||
&self,
|
||||
_: bool,
|
||||
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
|
||||
) -> Result<SystemUpdateDetail, SystemOperationError> {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
|
||||
@@ -1467,6 +1471,7 @@ impl SystemOperations for UnusedSystem {
|
||||
async fn perform_update(
|
||||
&self,
|
||||
_: Option<String>,
|
||||
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
|
||||
_: Arc<dyn gateway_admin::ports::system::SystemUpdatePreflight>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
Err(unavailable_system())
|
||||
|
||||
@@ -125,8 +125,29 @@ impl SystemOperations for ProgressSystem {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
|
||||
async fn update_detail(&self, _: bool) -> Result<SystemUpdateDetail, SystemOperationError> {
|
||||
Err(unavailable_system())
|
||||
async fn update_detail(
|
||||
&self,
|
||||
_: bool,
|
||||
channel: Option<gateway_admin::model::system::SystemUpdateChannel>,
|
||||
) -> Result<SystemUpdateDetail, SystemOperationError> {
|
||||
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<String>,
|
||||
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
|
||||
_: Arc<dyn gateway_admin::ports::system::SystemUpdatePreflight>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
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" }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<SystemUpdateDetail, SystemOperationError> {
|
||||
async fn update_detail(
|
||||
&self,
|
||||
_: bool,
|
||||
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
|
||||
) -> Result<SystemUpdateDetail, SystemOperationError> {
|
||||
unreachable!("client route must not request update details")
|
||||
}
|
||||
|
||||
@@ -111,6 +116,7 @@ impl SystemOperations for VersionSystem {
|
||||
async fn perform_update(
|
||||
&self,
|
||||
_: Option<String>,
|
||||
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
|
||||
_: std::sync::Arc<dyn gateway_admin::ports::system::SystemUpdatePreflight>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
unreachable!("client route must not perform updates")
|
||||
|
||||
@@ -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, OperationError> {
|
||||
Self::read(
|
||||
&config.executable_path()?,
|
||||
config.web_dist_dir()?,
|
||||
&config.official_plugins_dir()?,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn backup(config: &SystemUpdateConfig) -> Result<Self, OperationError> {
|
||||
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<Self, OperationError> {
|
||||
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<String> {
|
||||
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::<Result<Vec<_>, _>>()?;
|
||||
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]);
|
||||
}
|
||||
}
|
||||
@@ -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<SystemUpdateConfig>,
|
||||
operation_lock: Arc<AsyncMutex<()>>,
|
||||
release_cache: Arc<ReleaseCache>,
|
||||
running_files: Arc<Result<ReleaseFiles, OperationError>>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
channel: Option<UpdateChannel>,
|
||||
preflight: Arc<dyn SystemUpdatePreflight>,
|
||||
) -> Result<SystemOperationAccepted, OperationError> {
|
||||
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<ReleaseFiles, OperationError> {
|
||||
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<ReleaseFiles, OperationError> {
|
||||
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<UpdateChannel>,
|
||||
) -> Result<UpdateChannel, OperationError> {
|
||||
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<SystemUpdateStatus, OperationError> {
|
||||
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<SystemVersion, OperationError> {
|
||||
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<SystemUpdateDetail, OperationError> {
|
||||
match self.release_cache.detail(&self.config, refresh).await {
|
||||
async fn update_detail(
|
||||
&self,
|
||||
refresh: bool,
|
||||
channel: Option<UpdateChannel>,
|
||||
) -> Result<SystemUpdateDetail, OperationError> {
|
||||
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<String>,
|
||||
channel: Option<UpdateChannel>,
|
||||
preflight: Arc<dyn SystemUpdatePreflight>,
|
||||
) -> Result<SystemOperationAccepted, OperationError> {
|
||||
self.start_update(target_version, preflight)
|
||||
self.start_update(target_version, channel, preflight)
|
||||
}
|
||||
|
||||
async fn update_status(&self) -> Result<SystemUpdateStatus, OperationError> {
|
||||
@@ -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<String>,
|
||||
warning: Option<String>,
|
||||
) -> SystemUpdateDetail {
|
||||
SystemUpdateDetail {
|
||||
policy: update_policy(config, channel),
|
||||
current_version: config.version.clone(),
|
||||
latest_version: config.version.clone(),
|
||||
has_update: false,
|
||||
|
||||
@@ -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<Option<CachedRelease>>,
|
||||
request_sequence: AtomicU64,
|
||||
}
|
||||
|
||||
impl ReleaseCache {
|
||||
@@ -69,9 +50,15 @@ impl ReleaseCache {
|
||||
&self,
|
||||
config: &SystemUpdateConfig,
|
||||
refresh: bool,
|
||||
channel: UpdateChannel,
|
||||
) -> Result<SystemUpdateDetail, OperationError> {
|
||||
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<Option<GitHubRelease>, 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<semver::Version> {
|
||||
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<T: serde::de::DeserializeOwned>(
|
||||
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<UpdateChannel> {
|
||||
}
|
||||
|
||||
/// 检查更新与执行更新共用同一规则;构建元数据不构成更新。
|
||||
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(())
|
||||
|
||||
@@ -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<String>,
|
||||
current_version: Option<String>,
|
||||
#[serde(default)]
|
||||
current_files: Option<ReleaseFiles>,
|
||||
#[serde(default)]
|
||||
previous_files: Option<ReleaseFiles>,
|
||||
#[serde(default)]
|
||||
operation: PersistedOperation,
|
||||
}
|
||||
|
||||
@@ -190,13 +195,14 @@ impl UpdateOperation {
|
||||
|
||||
pub(crate) fn complete(
|
||||
&mut self,
|
||||
result: &Result<(), OperationError>,
|
||||
result: &Result<ReleaseFiles, OperationError>,
|
||||
) -> 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<String>,
|
||||
files: Option<ReleaseFiles>,
|
||||
error: Option<String>,
|
||||
) -> 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<SystemUpdateStatus, OperationError> {
|
||||
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<SystemUpdateStatus, OperationError> {
|
||||
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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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<String>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
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]
|
||||
|
||||
@@ -732,7 +732,11 @@ impl SystemOperations for UnusedAdminRuntime {
|
||||
Err(unused_system())
|
||||
}
|
||||
|
||||
async fn update_detail(&self, _: bool) -> Result<SystemUpdateDetail, SystemOperationError> {
|
||||
async fn update_detail(
|
||||
&self,
|
||||
_: bool,
|
||||
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
|
||||
) -> Result<SystemUpdateDetail, SystemOperationError> {
|
||||
Err(unused_system())
|
||||
}
|
||||
|
||||
@@ -743,6 +747,7 @@ impl SystemOperations for UnusedAdminRuntime {
|
||||
async fn perform_update(
|
||||
&self,
|
||||
_: Option<String>,
|
||||
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
|
||||
_: Arc<dyn gateway_admin::ports::system::SystemUpdatePreflight>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
Err(unused_system())
|
||||
|
||||
+23
-11
@@ -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 提供以下在线更新运行参数:
|
||||
|
||||
|
||||
+12
-4
@@ -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#镜像升级与源码构建)。
|
||||
|
||||
@@ -709,7 +709,12 @@ worker 复用连接测试探针执行真实上游调用,并按冻结代次处
|
||||
还要通过全部启用插件的静态兼容检查,文件交换前后复核同一插件配置版本,失败或取消时恢复二进制、Web 与
|
||||
官方插件目录。
|
||||
Host 在受理时持久化任务并转交后台执行,任务持有操作锁与终态写入责任,不依赖 HTTP 请求的生命周期。
|
||||
状态文件是更新结果的权威来源,SSE 终态在状态落盘后发送;Host 关闭与任务析构都必须收敛状态。
|
||||
状态文件记录操作结果,SSE 终态在状态落盘后发送;Host 关闭与任务析构都必须收敛状态。
|
||||
安装状态由 Host 对照启动时的发行文件指纹、磁盘文件与安装记录校准,历史成功记录本身不构成待重启事实。
|
||||
二进制、Web 资源、官方插件目录及回滚备份须整体匹配,无法核实的备份不参与回滚。
|
||||
通道是单次查询或安装的参数,不持久化偏好;默认按运行版本推导,检查和执行共用候选策略,
|
||||
缓存隔离通道并拒绝过期写回,安装在受理时冻结确认的通道与目标;
|
||||
Admin/API 只转发策略事实,前端分别展示运行版本、通道候选与待生效版本。
|
||||
|
||||
PostgreSQL 备份恢复属于人工维护操作。当前没有部署级维护模式开关,需要先停止应用,
|
||||
离线处理快照中的非终态任务、计划游标和到期清理条件,再重新验证对象存储并恢复计划。
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { SystemUpdateDetail } from '@/api'
|
||||
import { BaseButton, BaseConfirmModal, BaseEmpty, BaseMarkdown, BaseModal, BaseScrollbar, toast } from '@codex-proxy/ui'
|
||||
import type { SystemUpdateChannel, SystemUpdateDetail } from '@/api'
|
||||
import { BaseButton, BaseConfirmModal, BaseMarkdown, BaseModal, BasePopover, BaseScrollbar, BaseSegmented, BaseSkeleton, toast } from '@codex-proxy/ui'
|
||||
|
||||
import {
|
||||
ArrowUpCircle,
|
||||
Circle,
|
||||
CircleHelp,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Power,
|
||||
RefreshCw,
|
||||
@@ -28,18 +30,22 @@ const {
|
||||
updateInfo,
|
||||
loading,
|
||||
checking,
|
||||
changingChannel,
|
||||
selectedChannel,
|
||||
availableChannels,
|
||||
canChangeChannel,
|
||||
restartTargetVersion,
|
||||
updating,
|
||||
restarting,
|
||||
updateError,
|
||||
updateSuccess,
|
||||
needRestart,
|
||||
updateLogs,
|
||||
updateStreaming,
|
||||
updateStreamError,
|
||||
hasUpdate,
|
||||
hasCandidateUpdate: hasUpdate,
|
||||
canUpdate,
|
||||
} = storeToRefs(systemUpdateStore)
|
||||
const { loadSystem, checkUpdates, updateNow, restartNow } = systemUpdateStore
|
||||
const { loadSystem, checkUpdates, changeChannel, updateNow, restartNow } = systemUpdateStore
|
||||
|
||||
const updateLogScrollbar = useTemplateRef<InstanceType<typeof BaseScrollbar>>('updateLogScrollbar')
|
||||
const updateConfirmOpen = shallowRef(false)
|
||||
@@ -51,10 +57,11 @@ const presentation = computed(() => resolveSystemUpdatePresentation({
|
||||
version: version.value,
|
||||
updateInfo: updateInfo.value,
|
||||
loading: loading.value,
|
||||
checking: checking.value || changingChannel.value,
|
||||
restarting: restarting.value,
|
||||
updating: updating.value,
|
||||
updateError: updateError.value,
|
||||
updateSuccess: updateSuccess.value,
|
||||
needRestart: needRestart.value,
|
||||
hasUpdate: hasUpdate.value,
|
||||
updateStreaming: updateStreaming.value,
|
||||
updateStreamError: updateStreamError.value,
|
||||
@@ -70,10 +77,32 @@ const updateLogRows = computed(() =>
|
||||
})),
|
||||
)
|
||||
|
||||
const channelLabels = {
|
||||
stable: { label: 'Stable · 正式', description: '仅正式版本' },
|
||||
rc: { label: 'RC · 候选', description: '包含 RC 和正式版本' },
|
||||
beta: { label: 'Beta · 测试', description: '包含 Beta、RC 和正式版本' },
|
||||
alpha: { label: 'Alpha · 早期', description: '包含所有常规预发布和正式版本' },
|
||||
exp: { label: 'Exp · 实验', description: '仅当前实验线' },
|
||||
}
|
||||
const channelOptions = computed(() => availableChannels.value.map(value => ({
|
||||
value,
|
||||
label: { stable: 'Stable', rc: 'RC', beta: 'Beta', alpha: 'Alpha', exp: 'Exp' }[value],
|
||||
})))
|
||||
|
||||
async function handleChannelChange(value: string) {
|
||||
if (!availableChannels.value.includes(value as SystemUpdateChannel))
|
||||
return
|
||||
try {
|
||||
await changeChannel(value as SystemUpdateChannel)
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
|
||||
const hasReleaseNotes = computed(() => Boolean(updateInfo.value?.notes?.trim()))
|
||||
const releaseNotesLoading = computed(() => loading.value || changingChannel.value)
|
||||
|
||||
const showUpdateProgress = computed(
|
||||
() => hasUpdate.value || updating.value || restarting.value || updateLogRows.value.length > 0,
|
||||
() => updating.value || restarting.value || updateLogRows.value.length > 0,
|
||||
)
|
||||
|
||||
async function scrollUpdateLogsToBottom() {
|
||||
@@ -111,7 +140,7 @@ async function handleUpdateRequest() {
|
||||
toast.error(data.warning)
|
||||
return
|
||||
}
|
||||
if (!data?.hasUpdate) {
|
||||
if (!data?.hasUpdate || !canUpdate.value) {
|
||||
toast.success('当前没有可用更新')
|
||||
return
|
||||
}
|
||||
@@ -126,7 +155,7 @@ async function handleUpdateRequest() {
|
||||
updateConfirmOpen.value = true
|
||||
return
|
||||
}
|
||||
await runConfirmedUpdate(remoteTargetVersion)
|
||||
await runConfirmedUpdate(remoteTargetVersion, data.policy.channel)
|
||||
}
|
||||
catch {}
|
||||
finally {
|
||||
@@ -134,9 +163,9 @@ async function handleUpdateRequest() {
|
||||
}
|
||||
}
|
||||
|
||||
async function runConfirmedUpdate(targetVersion: string) {
|
||||
async function runConfirmedUpdate(targetVersion: string, channel: SystemUpdateChannel) {
|
||||
try {
|
||||
const result = await updateNow(targetVersion)
|
||||
const result = await updateNow(targetVersion, channel)
|
||||
if (result) {
|
||||
toast.success('更新已开始')
|
||||
}
|
||||
@@ -146,12 +175,13 @@ async function runConfirmedUpdate(targetVersion: string) {
|
||||
|
||||
async function handleConfirmUpdate() {
|
||||
const targetVersion = normalizeSystemVersion(updateConfirmInfo.value?.latestVersion)
|
||||
if (!targetVersion)
|
||||
if (!targetVersion || !updateConfirmInfo.value)
|
||||
return
|
||||
|
||||
const channel = updateConfirmInfo.value.policy.channel
|
||||
updateConfirmOpen.value = false
|
||||
await nextTick()
|
||||
await runConfirmedUpdate(targetVersion)
|
||||
await runConfirmedUpdate(targetVersion, channel)
|
||||
}
|
||||
|
||||
async function handleRestart() {
|
||||
@@ -233,6 +263,7 @@ watch(
|
||||
:href="item.releaseUrl"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
:class="{ invisible: checking || changingChannel }"
|
||||
class="inline-flex shrink-0 items-center gap-1 text-cp-xs leading-none font-bold text-cp-link transition-colors hover:text-cp-link-hover"
|
||||
>
|
||||
发布页
|
||||
@@ -240,14 +271,27 @@ watch(
|
||||
</a>
|
||||
</div>
|
||||
<p
|
||||
class="mt-2 mb-0 truncate font-mono text-cp leading-none font-bold text-cp-text"
|
||||
class="mt-2 mb-0 h-[1em] truncate font-mono text-cp leading-none font-bold text-cp-text"
|
||||
:title="item.title || item.value"
|
||||
>
|
||||
{{ item.value }}
|
||||
<BaseSkeleton
|
||||
v-if="loading || ((checking || changingChannel) && item.key === 'latest')"
|
||||
class="h-full w-20 max-w-full"
|
||||
aria-label="正在检查版本"
|
||||
/>
|
||||
<template v-else>
|
||||
{{ item.value }}
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="needRestart" class="m-0 text-cp-sm font-emphasis text-cp-success">
|
||||
v{{ restartTargetVersion }} 已安装,重启后生效
|
||||
</p>
|
||||
<p v-if="updateInfo?.unsupportedReason" class="m-0 text-cp-sm text-cp-text-secondary">
|
||||
{{ updateInfo.unsupportedReason }}
|
||||
</p>
|
||||
<p
|
||||
v-if="updateError || updateInfo?.warning"
|
||||
class="m-0 rounded-cp bg-cp-error-container px-3 py-2 text-cp-sm leading-normal font-bold text-cp-error-on-container"
|
||||
@@ -257,7 +301,7 @@ watch(
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="hasReleaseNotes"
|
||||
v-if="hasReleaseNotes || releaseNotesLoading"
|
||||
class="grid gap-2 rounded-cp-card bg-cp-fill-quaternary px-4 py-3.5"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
@@ -265,12 +309,24 @@ watch(
|
||||
发布说明
|
||||
</p>
|
||||
<span class="font-mono text-cp-xs font-emphasis text-cp-text-quaternary">
|
||||
{{ presentation.releaseVersion }}
|
||||
<BaseSkeleton v-if="releaseNotesLoading" shape="text" class="w-14" aria-hidden="true" />
|
||||
<template v-else>{{ presentation.releaseVersion }}</template>
|
||||
</span>
|
||||
</div>
|
||||
<BaseScrollbar class="-mx-4" max-height="160px">
|
||||
<div class="px-4">
|
||||
<BaseMarkdown :source="updateInfo?.notes" />
|
||||
<div
|
||||
class="relative px-4"
|
||||
:class="{ 'min-h-40': releaseNotesLoading && !hasReleaseNotes }"
|
||||
:aria-busy="releaseNotesLoading"
|
||||
>
|
||||
<div :class="{ invisible: releaseNotesLoading }" :aria-hidden="releaseNotesLoading || undefined">
|
||||
<BaseMarkdown :source="updateInfo?.notes" />
|
||||
</div>
|
||||
<div v-if="releaseNotesLoading" class="absolute inset-x-4 top-0 grid h-full content-start gap-3 overflow-hidden py-1" aria-hidden="true">
|
||||
<BaseSkeleton shape="text" class="w-20" />
|
||||
<BaseSkeleton shape="text" class="w-4/5" />
|
||||
<BaseSkeleton shape="text" class="w-3/5" />
|
||||
</div>
|
||||
</div>
|
||||
</BaseScrollbar>
|
||||
</section>
|
||||
@@ -322,17 +378,42 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
</BaseScrollbar>
|
||||
<div v-else class="grid h-30 place-items-center px-4 pb-4">
|
||||
<BaseEmpty title="暂无进度" :icon="Terminal" size="sm" surface="none" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="mr-auto flex min-w-0 basis-full items-center gap-2 sm:basis-auto">
|
||||
<BaseSkeleton v-if="loading" class="h-8 w-52" aria-label="正在读取运行通道" />
|
||||
<BaseSegmented
|
||||
v-else
|
||||
:model-value="selectedChannel"
|
||||
:options="channelOptions"
|
||||
label="更新通道"
|
||||
size="sm"
|
||||
:disabled="!canChangeChannel || preparingUpdate || updateConfirmOpen"
|
||||
@update:model-value="handleChannelChange"
|
||||
/>
|
||||
<BasePopover placement="top-start">
|
||||
<template #trigger>
|
||||
<BaseButton variant="ghost" size="sm" square aria-label="更新通道说明">
|
||||
<CircleHelp class="size-4" />
|
||||
</BaseButton>
|
||||
</template>
|
||||
<div class="grid w-72 gap-2 p-3 text-cp-sm text-cp-text-secondary">
|
||||
<p v-for="channel in availableChannels" :key="channel" class="m-0">
|
||||
<strong class="font-heavy text-cp-text">{{ channelLabels[channel].label }}</strong>
|
||||
· {{ channelLabels[channel].description }}
|
||||
</p>
|
||||
<p class="m-0 text-cp-text-quaternary">
|
||||
选择仅用于本次检查,重新打开时按运行版本选择通道,不会自动安装或降级
|
||||
</p>
|
||||
</div>
|
||||
</BasePopover>
|
||||
</div>
|
||||
<BaseButton
|
||||
variant="secondary"
|
||||
:loading="checking"
|
||||
:disabled="loading || updating || restarting"
|
||||
:disabled="loading || updating || restarting || changingChannel || preparingUpdate"
|
||||
@click="handleCheckUpdates(true)"
|
||||
>
|
||||
<template #loading>
|
||||
@@ -344,7 +425,7 @@ watch(
|
||||
检查更新
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-if="updateSuccess && needRestart"
|
||||
v-if="needRestart"
|
||||
variant="primary"
|
||||
:loading="restarting"
|
||||
:disabled="updating"
|
||||
@@ -363,9 +444,9 @@ watch(
|
||||
@click="handleUpdateRequest"
|
||||
>
|
||||
<template #icon>
|
||||
<ArrowUpCircle class="size-4" />
|
||||
<Download class="size-4" />
|
||||
</template>
|
||||
立即更新
|
||||
{{ updating ? '更新中' : '下载并更新' }}
|
||||
</BaseButton>
|
||||
</template>
|
||||
</BaseModal>
|
||||
@@ -373,7 +454,7 @@ watch(
|
||||
<BaseConfirmModal
|
||||
v-model="updateConfirmOpen"
|
||||
title="发现新的更新版本"
|
||||
description="检测到远端 latest 与当前显示的目标版本不一致"
|
||||
description="所选通道的最新版本已变化"
|
||||
confirm-text="确认更新"
|
||||
:loading="updating"
|
||||
:confirm-disabled="!updateConfirmInfo?.latestVersion"
|
||||
|
||||
@@ -5,10 +5,11 @@ interface SystemUpdatePresentationInput {
|
||||
version: SystemVersion | null
|
||||
updateInfo: SystemUpdateDetail | null
|
||||
loading: boolean
|
||||
checking: boolean
|
||||
restarting: boolean
|
||||
updating: boolean
|
||||
updateError: string
|
||||
updateSuccess: boolean
|
||||
needRestart: boolean
|
||||
hasUpdate: boolean
|
||||
updateStreaming: boolean
|
||||
updateStreamError: string
|
||||
@@ -43,9 +44,9 @@ export function resolveSystemUpdateLogClasses(level: string) {
|
||||
}
|
||||
|
||||
function resolveStatus(input: SystemUpdatePresentationInput) {
|
||||
if (input.restarting || input.updating) {
|
||||
if (input.restarting || input.updating || input.checking || input.loading) {
|
||||
return {
|
||||
label: input.restarting ? '重启中' : '更新中',
|
||||
label: input.restarting ? '重启中' : input.updating ? '更新中' : '检查中',
|
||||
icon: RefreshCw,
|
||||
badge: 'bg-cp-info-container text-cp-info-on-container',
|
||||
iconClass: 'text-cp-info',
|
||||
@@ -59,9 +60,9 @@ function resolveStatus(input: SystemUpdatePresentationInput) {
|
||||
iconClass: 'text-cp-error',
|
||||
}
|
||||
}
|
||||
if (input.updateSuccess || input.hasUpdate || input.updateInfo) {
|
||||
if (input.needRestart || input.hasUpdate || input.updateInfo) {
|
||||
return {
|
||||
label: input.updateSuccess ? '已更新' : input.hasUpdate ? '有可用更新' : '无可用更新',
|
||||
label: input.needRestart ? '待重启' : input.hasUpdate ? '有可用更新' : '无可用更新',
|
||||
icon: input.hasUpdate ? ArrowUpCircle : CheckCircle2,
|
||||
badge: 'bg-cp-success-container text-cp-success-on-container',
|
||||
iconClass: 'text-cp-success',
|
||||
@@ -80,12 +81,12 @@ function resolveSummaryItems(input: SystemUpdatePresentationInput) {
|
||||
{
|
||||
key: 'current',
|
||||
label: '当前版本',
|
||||
value: input.loading ? '...' : versionLabel(input.version?.version),
|
||||
title: input.version?.version,
|
||||
value: input.loading ? '...' : versionLabel(input.updateInfo?.currentVersion ?? input.version?.version),
|
||||
title: input.updateInfo?.currentVersion ?? input.version?.version,
|
||||
},
|
||||
{
|
||||
key: 'latest',
|
||||
label: '最新版本',
|
||||
label: '通道最新',
|
||||
value: versionLabel(input.updateInfo?.latestVersion),
|
||||
title: input.updateInfo?.latestVersion,
|
||||
releaseUrl: input.updateInfo?.releaseUrl,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SystemUpdateStatus } from '@/api'
|
||||
import type { SystemUpdateChannel, SystemUpdatePolicy, SystemUpdateStatus } from '@/api'
|
||||
import { until, useEventSource, useTimeoutPoll } from '@vueuse/core'
|
||||
import { delay } from 'es-toolkit'
|
||||
import { defineStore } from 'pinia'
|
||||
@@ -38,6 +38,7 @@ type SystemUpdatePhase
|
||||
= | { kind: 'idle' }
|
||||
| { kind: 'loading' }
|
||||
| { kind: 'checking' }
|
||||
| { kind: 'changing_channel' }
|
||||
| { kind: 'ready' }
|
||||
| { kind: 'updating' }
|
||||
| { kind: 'restart_required' }
|
||||
@@ -47,6 +48,7 @@ type SystemUpdatePhase
|
||||
const UPDATE_BUSY_PHASES = new Set<SystemUpdatePhase['kind']>([
|
||||
'loading',
|
||||
'checking',
|
||||
'changing_channel',
|
||||
'updating',
|
||||
'restarting',
|
||||
])
|
||||
@@ -56,13 +58,12 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
|
||||
const updateInfo = shallowRef<Awaited<ReturnType<typeof getSystemUpdateDetail>> | null>(null)
|
||||
const phase = shallowRef<SystemUpdatePhase>({ kind: 'idle' })
|
||||
const updateError = shallowRef('')
|
||||
const updateSuccess = shallowRef(false)
|
||||
const needRestart = shallowRef(false)
|
||||
const statusAvailable = shallowRef(false)
|
||||
const policy = shallowRef<SystemUpdatePolicy | null>(null)
|
||||
const loadedOnce = shallowRef(false)
|
||||
const updateLogs = ref<SystemUpdateEvent[]>([])
|
||||
const updateStreaming = shallowRef(false)
|
||||
const updateStreamError = shallowRef('')
|
||||
const restartTargetVersion = shallowRef('')
|
||||
const updateStatus = shallowRef<SystemUpdateStatus | null>(null)
|
||||
const {
|
||||
data: updateEventMessage,
|
||||
@@ -84,6 +85,7 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
|
||||
let loadSystemPromise: Promise<void> | undefined
|
||||
let statusRequest: Promise<void> | undefined
|
||||
let statusGeneration = 0
|
||||
let detailGeneration = 0
|
||||
let submitting = false
|
||||
let activeOperationId: string | null = null
|
||||
let unconfirmedPreviousId: string | null | undefined
|
||||
@@ -92,13 +94,24 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
|
||||
const phaseKind = computed(() => phase.value.kind)
|
||||
const loading = computed(() => phaseKind.value === 'loading')
|
||||
const checking = computed(() => phaseKind.value === 'checking')
|
||||
const changingChannel = computed(() => phaseKind.value === 'changing_channel')
|
||||
const updating = computed(() => phaseKind.value === 'updating')
|
||||
const restarting = computed(() => phaseKind.value === 'restarting')
|
||||
|
||||
const hasUpdate = computed(() => Boolean(updateInfo.value?.hasUpdate ?? version.value?.hasUpdate))
|
||||
const needRestart = computed(() => Boolean(updateStatus.value?.needRestart))
|
||||
const restartTargetVersion = computed(() => needRestart.value ? normalizeSystemVersion(updateStatus.value?.currentVersion) : '')
|
||||
const selectedChannel = computed(() => policy.value?.channel ?? 'stable')
|
||||
const availableChannels = computed(() => policy.value?.availableChannels ?? [])
|
||||
const canChangeChannel = computed(() => availableChannels.value.length > 1
|
||||
&& Boolean(updateInfo.value) && !updateInfo.value?.unsupportedReason
|
||||
&& statusAvailable.value && !needRestart.value && !UPDATE_BUSY_PHASES.has(phaseKind.value))
|
||||
const hasUpdate = computed(() => Boolean(version.value?.hasUpdate))
|
||||
const hasCandidateUpdate = computed(() => Boolean(updateInfo.value?.hasUpdate))
|
||||
const canUpdate = computed(
|
||||
() =>
|
||||
hasUpdate.value
|
||||
hasCandidateUpdate.value
|
||||
&& statusAvailable.value
|
||||
&& updateInfo.value?.policy.channel === selectedChannel.value
|
||||
&& Boolean(updateInfo.value?.updateSupported)
|
||||
&& !needRestart.value
|
||||
&& !UPDATE_BUSY_PHASES.has(phaseKind.value),
|
||||
@@ -110,9 +123,6 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
|
||||
|
||||
function resetUpdateResult() {
|
||||
updateError.value = ''
|
||||
updateSuccess.value = false
|
||||
needRestart.value = false
|
||||
restartTargetVersion.value = ''
|
||||
}
|
||||
|
||||
function appendUpdateLog(log: SystemUpdateEvent) {
|
||||
@@ -184,6 +194,7 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
|
||||
|
||||
function applyUpdateStatus(status: SystemUpdateStatus) {
|
||||
const operation = status.operation
|
||||
statusAvailable.value = true
|
||||
updateStatus.value = status
|
||||
if (unconfirmedPreviousId !== undefined && operation.operationId === unconfirmedPreviousId) {
|
||||
unconfirmedPreviousId = undefined
|
||||
@@ -196,9 +207,6 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
|
||||
unconfirmedPreviousId = undefined
|
||||
activeOperationId = operation.operationId
|
||||
updateError.value = ''
|
||||
updateSuccess.value = operation.status === 'succeeded' && status.needRestart
|
||||
needRestart.value = status.needRestart
|
||||
restartTargetVersion.value = status.needRestart ? normalizeSystemVersion(status.currentVersion) : ''
|
||||
if (operation.status === 'running') {
|
||||
setPhase({ kind: 'updating' })
|
||||
if (!statusPoll.isActive.value)
|
||||
@@ -210,13 +218,9 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
|
||||
disconnectUpdateEvents()
|
||||
if (operation.status === 'failed') {
|
||||
updateError.value = operation.error || operation.message || '更新失败'
|
||||
setPhase({ kind: 'failed' })
|
||||
return
|
||||
}
|
||||
if (status.needRestart && updateInfo.value) {
|
||||
updateInfo.value = { ...updateInfo.value, hasUpdate: false }
|
||||
}
|
||||
setPhase(status.needRestart ? { kind: 'restart_required' } : { kind: 'ready' })
|
||||
if (!['loading', 'checking', 'changing_channel'].includes(phaseKind.value))
|
||||
settlePhase()
|
||||
}
|
||||
|
||||
async function refreshUpdateStatus() {
|
||||
@@ -236,7 +240,9 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
|
||||
catch (error: unknown) {
|
||||
if (generation !== statusGeneration)
|
||||
return
|
||||
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
|
||||
statusAvailable.value = false
|
||||
const nonRetryable = error instanceof ApiError && error.status >= 400 && error.status < 500 && error.status !== 408 && error.status !== 429
|
||||
if (nonRetryable || (!updating.value && unconfirmedPreviousId === undefined)) {
|
||||
statusPoll.pause()
|
||||
disconnectUpdateEvents()
|
||||
updateError.value = errorMessage(error)
|
||||
@@ -256,25 +262,40 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
|
||||
return statusRequest
|
||||
}
|
||||
|
||||
function settlePhase() {
|
||||
setPhase(needRestart.value ? { kind: 'restart_required' } : updateError.value ? { kind: 'failed' } : { kind: 'ready' })
|
||||
}
|
||||
|
||||
async function loadDetail(refresh: boolean, generation: number, channel?: SystemUpdateChannel) {
|
||||
const detail = await getSystemUpdateDetail({ refresh, channel })
|
||||
if (generation !== detailGeneration)
|
||||
return
|
||||
updateInfo.value = detail
|
||||
policy.value = detail.policy
|
||||
loadedOnce.value = true
|
||||
if (!version.value)
|
||||
await loadVersion()
|
||||
if (generation !== detailGeneration)
|
||||
return
|
||||
if (version.value && detail.policy.channel === version.value.updateChannel) {
|
||||
// 侧栏只提示当前运行通道的更新,临时查看其他通道不会改变全局提示。
|
||||
version.value = { ...version.value, latestVersion: detail.latestVersion, hasUpdate: detail.hasUpdate, updateCached: detail.cached, updateWarning: detail.warning }
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSystem(refresh = false) {
|
||||
if (loadSystemPromise)
|
||||
return loadSystemPromise
|
||||
if (updating.value || restarting.value)
|
||||
return
|
||||
|
||||
if (!UPDATE_BUSY_PHASES.has(phaseKind.value)) {
|
||||
setPhase({ kind: 'loading' })
|
||||
}
|
||||
const generation = ++detailGeneration
|
||||
setPhase({ kind: 'loading' })
|
||||
loadSystemPromise = (async () => {
|
||||
await Promise.all([
|
||||
refreshUpdateStatus(),
|
||||
(async () => {
|
||||
updateInfo.value = await getSystemUpdateDetail({ refresh })
|
||||
if (!version.value)
|
||||
version.value = await getSystemVersion()
|
||||
loadedOnce.value = true
|
||||
})(),
|
||||
loadDetail(refresh, generation),
|
||||
])
|
||||
if (needRestart.value && updateInfo.value)
|
||||
updateInfo.value = { ...updateInfo.value, hasUpdate: false }
|
||||
})()
|
||||
|
||||
try {
|
||||
@@ -282,7 +303,7 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
|
||||
}
|
||||
finally {
|
||||
if (phaseKind.value === 'loading')
|
||||
setPhase({ kind: 'ready' })
|
||||
settlePhase()
|
||||
loadSystemPromise = undefined
|
||||
}
|
||||
}
|
||||
@@ -303,29 +324,60 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
|
||||
}
|
||||
|
||||
async function checkUpdates(refresh = true) {
|
||||
if (checking.value || updating.value || restarting.value)
|
||||
if (UPDATE_BUSY_PHASES.has(phaseKind.value))
|
||||
return updateInfo.value
|
||||
|
||||
const generation = ++detailGeneration
|
||||
setPhase({ kind: 'checking' })
|
||||
resetUpdateResult()
|
||||
try {
|
||||
updateInfo.value = await getSystemUpdateDetail({ refresh })
|
||||
if (!version.value)
|
||||
version.value = await getSystemVersion()
|
||||
loadedOnce.value = true
|
||||
await loadDetail(refresh, generation, selectedChannel.value)
|
||||
if (generation !== detailGeneration)
|
||||
return
|
||||
await refreshUpdateStatus()
|
||||
return updateInfo.value
|
||||
}
|
||||
finally {
|
||||
if (phaseKind.value === 'checking')
|
||||
setPhase({ kind: 'ready' })
|
||||
if (generation === detailGeneration && phaseKind.value === 'checking')
|
||||
settlePhase()
|
||||
}
|
||||
}
|
||||
|
||||
async function updateNow(targetVersion: string) {
|
||||
async function changeChannel(channel: SystemUpdateChannel) {
|
||||
if (!canChangeChannel.value || channel === selectedChannel.value)
|
||||
return
|
||||
const generation = ++detailGeneration
|
||||
setPhase({ kind: 'changing_channel' })
|
||||
resetUpdateResult()
|
||||
try {
|
||||
if (policy.value)
|
||||
policy.value = { ...policy.value, channel }
|
||||
// 选择只影响本次检查,重新打开时恢复运行通道;旧候选不能用于新通道下载。
|
||||
await loadDetail(true, generation, channel)
|
||||
if (generation !== detailGeneration)
|
||||
return
|
||||
await refreshUpdateStatus()
|
||||
}
|
||||
catch (error: unknown) {
|
||||
if (generation !== detailGeneration)
|
||||
return
|
||||
if (updateInfo.value?.policy.channel !== selectedChannel.value)
|
||||
updateInfo.value = null
|
||||
updateError.value = errorMessage(error)
|
||||
throw error
|
||||
}
|
||||
finally {
|
||||
if (generation === detailGeneration && phaseKind.value === 'changing_channel')
|
||||
settlePhase()
|
||||
}
|
||||
}
|
||||
|
||||
async function updateNow(targetVersion: string, channel: SystemUpdateChannel) {
|
||||
const confirmedTargetVersion = normalizeSystemVersion(targetVersion)
|
||||
if (!canUpdate.value || !confirmedTargetVersion)
|
||||
if (!canUpdate.value || !confirmedTargetVersion || channel !== selectedChannel.value
|
||||
|| confirmedTargetVersion !== normalizeSystemVersion(updateInfo.value?.latestVersion)) {
|
||||
return null
|
||||
}
|
||||
|
||||
statusGeneration += 1
|
||||
submitting = true
|
||||
@@ -338,7 +390,7 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
|
||||
setPhase({ kind: 'updating' })
|
||||
try {
|
||||
await connectUpdateEvents(true)
|
||||
const result = await performSystemUpdate({ targetVersion: confirmedTargetVersion }, { silent: true })
|
||||
const result = await performSystemUpdate({ targetVersion: confirmedTargetVersion, channel }, { silent: true })
|
||||
activeOperationId = result.operationId
|
||||
updateLogs.value = updateLogs.value.filter(event => event.operationId === result.operationId)
|
||||
return result
|
||||
@@ -429,20 +481,26 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
|
||||
phase,
|
||||
loading,
|
||||
checking,
|
||||
changingChannel,
|
||||
selectedChannel,
|
||||
availableChannels,
|
||||
canChangeChannel,
|
||||
restartTargetVersion,
|
||||
updating,
|
||||
restarting,
|
||||
updateError,
|
||||
updateSuccess,
|
||||
needRestart,
|
||||
loadedOnce,
|
||||
updateLogs,
|
||||
updateStreaming,
|
||||
updateStreamError,
|
||||
hasUpdate,
|
||||
hasCandidateUpdate,
|
||||
canUpdate,
|
||||
loadVersion,
|
||||
loadSystem,
|
||||
checkUpdates,
|
||||
changeChannel,
|
||||
updateNow,
|
||||
restartNow,
|
||||
connectUpdateEvents,
|
||||
|
||||
Reference in New Issue
Block a user