From b4d11694ae9a544f30f5159d4c2da9d3de75dfda Mon Sep 17 00:00:00 2001 From: Zyy Date: Wed, 30 Sep 2026 01:36:32 +0800 Subject: [PATCH] fix(update): confirm incompatible plugin shutdown before restart Co-authored-by: Codex --- .github/workflows/release.yml | 2 + backend/crates/gateway-admin/src/lib.rs | 1 + .../src/model/plugins/instances.rs | 1 + .../crates/gateway-admin/src/model/system.rs | 18 ++ .../crates/gateway-admin/src/ports/plugins.rs | 13 + .../crates/gateway-admin/src/ports/system.rs | 29 +- .../src/use_case/plugin_update.rs | 250 ++++++++++++---- .../src/use_case/plugins/artifacts.rs | 7 +- .../src/use_case/plugins/instances.rs | 50 +++- .../src/use_case/plugins/official.rs | 39 ++- .../gateway-admin/src/use_case/system.rs | 47 ++- .../gateway-admin/tests/use_case/mod.rs | 5 +- .../tests/use_case/plugin_update.rs | 278 ++++++++++++++++-- .../tests/use_case/plugins/artifacts.rs | 21 +- .../tests/use_case/plugins/instances.rs | 91 +++++- .../tests/use_case/plugins/official.rs | 34 +++ .../gateway-admin/tests/use_case/system.rs | 5 +- .../src/admin/plugins/instances.rs | 2 + .../crates/gateway-api/src/admin/system.rs | 37 ++- backend/crates/gateway-api/tests/admin/mod.rs | 5 +- .../gateway-api/tests/admin/plugins/mod.rs | 11 +- .../crates/gateway-api/tests/admin/system.rs | 5 +- backend/crates/gateway-api/tests/support.rs | 5 +- .../gateway-host/src/system_update/mod.rs | 52 +++- .../gateway-host/tests/system_update/mod.rs | 95 +++++- .../runtime/src/package/inspection.rs | 3 + .../runtime/tests/support/environment.rs | 5 +- .../src/postgres/plugins/artifacts.rs | 8 + .../src/postgres/plugins/instances.rs | 39 +++ .../tests/postgres/plugins/instances.rs | 83 ++++++ deploy/README.md | 3 +- docs/api.md | 19 +- docs/architecture.md | 6 +- docs/plugins.md | 4 +- frontend/src/api/modules/plugins.ts | 1 + frontend/src/api/modules/system.ts | 15 +- .../components/SystemUpdateModal/index.vue | 71 ++++- frontend/src/stores/modules/system-update.ts | 6 +- .../plugins/components/PluginCatalogPanel.vue | 7 +- .../components/PluginCompatibilityWarning.vue | 20 ++ .../plugins/components/PluginDetailModal.vue | 8 +- .../plugins/components/PluginHelpPopover.vue | 7 +- .../components/PluginLegacyConfigurations.vue | 6 +- .../plugins/components/PluginVersionCard.vue | 4 +- .../plugins/composables/usePluginInstances.ts | 4 + frontend/src/views/plugins/utils/catalog.ts | 7 +- 46 files changed, 1261 insertions(+), 168 deletions(-) create mode 100644 frontend/src/views/plugins/components/PluginCompatibilityWarning.vue diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 370d1f886..5bd673674 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -177,6 +177,8 @@ jobs: host_path = "backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json" host = JSON.parse(File.read(host_path)) abort "Invalid plugin host compatibility declaration: #{host_path}" unless host["schema_version"] == 2 + # 保留旧更新器可解析的传输格式,实际支持范围仍取自当前宿主合同。 + host = host.merge("schema_version" => 1, "permissions" => []) # 清单只依赖版本与兼容声明,平台归档和镜像消费同一份封口结果。 manifest = { "schema_version" => 1, diff --git a/backend/crates/gateway-admin/src/lib.rs b/backend/crates/gateway-admin/src/lib.rs index de83ac6f1..8be6917eb 100644 --- a/backend/crates/gateway-admin/src/lib.rs +++ b/backend/crates/gateway-admin/src/lib.rs @@ -422,6 +422,7 @@ async fn initialize_inner( let system_preflight = Arc::new(use_case::plugin_update::PluginSystemUpdatePreflight::new( store.plugins(), plugin_inspector.clone(), + snapshot.clone(), )); let system = Arc::new(DefaultSystemService::new(system, system_preflight)); let key_usage = Arc::new(use_case::key_usage::DefaultKeyUsageService::new( diff --git a/backend/crates/gateway-admin/src/model/plugins/instances.rs b/backend/crates/gateway-admin/src/model/plugins/instances.rs index f22635869..6e9470ee6 100644 --- a/backend/crates/gateway-admin/src/model/plugins/instances.rs +++ b/backend/crates/gateway-admin/src/model/plugins/instances.rs @@ -111,6 +111,7 @@ pub struct PluginInstanceRuntime { pub struct PluginInstanceView { pub instance: PluginInstance, pub configuration_required: bool, + pub compatibility_warning: Option, pub running: bool, pub published_revision: Option, pub runtime: PluginInstanceRuntime, diff --git a/backend/crates/gateway-admin/src/model/system.rs b/backend/crates/gateway-admin/src/model/system.rs index d468e15ec..5fc93bf47 100644 --- a/backend/crates/gateway-admin/src/model/system.rs +++ b/backend/crates/gateway-admin/src/model/system.rs @@ -162,3 +162,21 @@ impl SystemOperationAccepted { } } } + +/// 重启前的只读确认快照;目标发行与全局配置版本一起绑定用户确认。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SystemRestartPlan { + pub target_version: Option, + pub release_manifest_sha256: Option, + pub config_revision: u64, + pub incompatible_plugins: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SystemIncompatiblePlugin { + pub instance_id: String, + pub name: String, + pub reason: String, +} diff --git a/backend/crates/gateway-admin/src/ports/plugins.rs b/backend/crates/gateway-admin/src/ports/plugins.rs index 2a7eb3991..f3b31e3dd 100644 --- a/backend/crates/gateway-admin/src/ports/plugins.rs +++ b/backend/crates/gateway-admin/src/ports/plugins.rs @@ -219,6 +219,19 @@ pub trait PluginStore: Send + Sync { async fn configuration_versions(&self, _id: &str) -> AdminStoreResult> { Ok(Vec::new()) } + /// 在单个事务中停用确认快照中的实例,保留配置、密钥及私有状态。 + async fn disable_instances( + &self, + _ids: &[String], + _expected_revision: Revision, + _context: &MutationContext, + ) -> AdminStoreResult { + Err(super::store::AdminStoreError::new( + super::store::AdminStoreErrorKind::Unavailable, + "plugin", + "atomic plugin disable is unavailable", + )) + } async fn save_instance( &self, instance: PluginInstance, diff --git a/backend/crates/gateway-admin/src/ports/system.rs b/backend/crates/gateway-admin/src/ports/system.rs index 07257bb7c..a2cec80fd 100644 --- a/backend/crates/gateway-admin/src/ports/system.rs +++ b/backend/crates/gateway-admin/src/ports/system.rs @@ -77,10 +77,27 @@ pub trait SystemUpdatePreflight: Send + Sync { candidate: SystemUpdateCandidate, ) -> Result; + /// 回滚保持严格预检,不能把旧目标当成具有当前重启确认能力的版本。 + async fn validate_rollback( + &self, + candidate: SystemUpdateCandidate, + ) -> Result { + self.validate(candidate).await + } + /// 在文件交换临界点复核全局配置 CAS,不能用较早快照掩盖实例变更。 async fn confirm_revision(&self, expected: Revision) -> Result<(), SystemOperationError>; } +/// 重启临界区内复查候选,确认后停用不兼容插件,再允许进程退出。 +#[async_trait] +pub trait SystemRestartPreflight: Send + Sync { + async fn prepare( + &self, + candidate: Option, + ) -> Result<(), SystemOperationError>; +} + /// 版本、自更新、回滚和重启能力;实现唯一归 gateway-host。 #[async_trait] pub trait SystemOperations: Send + Sync { @@ -108,5 +125,15 @@ pub trait SystemOperations: Send + Sync { preflight: Arc, ) -> Result; - async fn restart(&self) -> Result; + /// 返回下一次启动实际使用的发行;源码运行没有封存发行清单。 + async fn restart_candidate( + &self, + ) -> Result, SystemOperationError> { + Ok(None) + } + + async fn restart( + &self, + preflight: Arc, + ) -> Result; } diff --git a/backend/crates/gateway-admin/src/use_case/plugin_update.rs b/backend/crates/gateway-admin/src/use_case/plugin_update.rs index 0ee92b7e1..3a466cd73 100644 --- a/backend/crates/gateway-admin/src/use_case/plugin_update.rs +++ b/backend/crates/gateway-admin/src/use_case/plugin_update.rs @@ -1,6 +1,14 @@ use std::{collections::BTreeMap, sync::Arc}; +use super::publish_committed; +use crate::model::{ + MutationContext, + system::{SystemIncompatiblePlugin, SystemRestartPlan}, +}; +use crate::ports::system::SystemRestartPreflight; use async_trait::async_trait; +use gateway_core::runtime::SnapshotControl; +use sha2::{Digest as _, Sha256}; use crate::{ model::{AdminErrorKind, Revision}, @@ -14,20 +22,26 @@ use crate::{ }, }; -use super::plugins::official::update_compatibility; +use super::plugins::official::{update_compatibility, validate_update_release}; /// 只读取启用实例及其固定包体,检查目标宿主合同;不准备或执行插件。 pub(crate) struct PluginSystemUpdatePreflight { store: Arc, inspector: Arc, + snapshots: Arc, } impl PluginSystemUpdatePreflight { pub(crate) fn new( store: Arc, inspector: Arc, + snapshots: Arc, ) -> Self { - Self { store, inspector } + Self { + store, + inspector, + snapshots, + } } } @@ -37,59 +51,28 @@ impl SystemUpdatePreflight for PluginSystemUpdatePreflight { &self, candidate: SystemUpdateCandidate, ) -> Result { - let target = semver::Version::parse(candidate.target_version.trim_start_matches('v')) - .map_err(|_| invalid("目标网关版本不合法"))?; - let compatibility = update_compatibility( - candidate.release_manifest.as_ref(), - candidate.target_version.trim_start_matches('v'), - ) - .map_err(|_| invalid("目标发行物缺少有效的插件兼容声明"))?; - let snapshot = self.store.load_instances().await.map_err(map_store_error)?; - let mut inspected = BTreeMap::new(); - for instance in snapshot - .instances - .iter() - .filter(|instance| instance.enabled) - { - let requirements = if let Some(requirements) = inspected.get(&instance.artifact_sha256) - { - requirements - } else { - let artifact = self - .store - .load_artifact(&instance.artifact_sha256) - .await - .map_err(map_store_error)?; - let requirements = self - .inspector - .compatibility(artifact.archive, instance.artifact_sha256.clone()) - .await - .map_err(map_inspection_error)?; - inspected.insert(instance.artifact_sha256.clone(), requirements); - inspected - .get(&instance.artifact_sha256) - .ok_or_else(|| internal("插件兼容性检查结果不可用"))? - }; - let host_versions = semver::VersionReq::parse(&requirements.host_version) - .map_err(|_| incompatible(&instance.id))?; - if !host_versions.matches(&target) - || !compatibility - .manifest_schema_versions - .contains(&requirements.manifest_schema_version) - || !compatibility - .protocol_versions - .contains(&requirements.protocol_version) - || requirements - .capabilities - .iter() - .any(|(capability, version)| { - !compatibility.supports_capability(capability, *version) - }) - { - return Err(incompatible(&instance.id)); - } + let target_version = candidate.target_version.trim_start_matches('v'); + semver::Version::parse(target_version).map_err(|_| invalid("目标网关版本不合法"))?; + // 下载阶段只校验发行身份;插件兼容性在用户点击重启时检查并确认。 + validate_update_release(candidate.release_manifest.as_ref(), target_version) + .map_err(|_| invalid("目标发行清单不合法或版本不匹配"))?; + Ok(self + .store + .load_instances() + .await + .map_err(map_store_error)? + .config_revision) + } + + async fn validate_rollback( + &self, + candidate: SystemUpdateCandidate, + ) -> Result { + let plan = self.plan(Some(candidate)).await?; + if !plan.incompatible_plugins.is_empty() { + return Err(conflict("启用插件与回滚目标不兼容,请先停用对应插件")); } - Ok(snapshot.config_revision) + Revision::new(plan.config_revision).map_err(|_| internal("插件配置版本不合法")) } async fn confirm_revision(&self, expected: Revision) -> Result<(), SystemOperationError> { @@ -127,10 +110,6 @@ fn map_inspection_error(error: crate::model::AdminError) -> SystemOperationError } } -fn incompatible(instance_id: &str) -> SystemOperationError { - conflict(format!("启用插件实例 {instance_id} 与目标网关版本不兼容")) -} - fn invalid(message: impl Into) -> SystemOperationError { SystemOperationError::new(SystemOperationErrorKind::Invalid, message) } @@ -142,3 +121,158 @@ fn conflict(message: impl Into) -> SystemOperationError { fn internal(message: impl Into) -> SystemOperationError { SystemOperationError::new(SystemOperationErrorKind::Internal, message) } + +impl PluginSystemUpdatePreflight { + pub(crate) async fn plan( + &self, + candidate: Option, + ) -> Result { + let snapshot = self.store.load_instances().await.map_err(map_store_error)?; + let mut plan = SystemRestartPlan { + target_version: candidate.as_ref().map(|item| item.target_version.clone()), + release_manifest_sha256: candidate + .as_ref() + .map(|item| hex::encode(Sha256::digest(&item.release_manifest))), + config_revision: snapshot.config_revision.get(), + incompatible_plugins: Vec::new(), + }; + let target = candidate + .as_ref() + .map(|item| semver::Version::parse(item.target_version.trim_start_matches('v'))) + .transpose() + .map_err(|_| invalid("目标网关版本不合法"))?; + if let Some(candidate) = &candidate { + validate_update_release( + &candidate.release_manifest, + candidate.target_version.trim_start_matches('v'), + ) + .map_err(|_| invalid("目标发行清单不合法或版本不匹配"))?; + } + // 未知目标合同不能证明兼容,列入待确认停用;不伪造目标支持范围。 + let host = candidate.as_ref().and_then(|item| { + update_compatibility( + &item.release_manifest, + item.target_version.trim_start_matches('v'), + ) + .ok() + }); + let mut inspected: BTreeMap> = BTreeMap::new(); + for instance in snapshot.instances.iter().filter(|item| item.enabled) { + let reason = if let Some(reason) = inspected.get(&instance.artifact_sha256) { + reason.clone() + } else { + let artifact = self + .store + .load_artifact(&instance.artifact_sha256) + .await + .map_err(map_store_error)?; + let reason = if let Some(target) = &target { + match ( + &host, + self.inspector + .compatibility(artifact.archive, instance.artifact_sha256.clone()) + .await, + ) { + (_, Err(error)) if error.kind() == AdminErrorKind::Invalid => { + Some("插件包或协议与目标版本不兼容".to_owned()) + } + (_, Err(error)) => return Err(map_inspection_error(error)), + (None, _) => Some("无法确认插件与目标版本兼容".to_owned()), + (Some(host), Ok(requirements)) => { + if !semver::VersionReq::parse(&requirements.host_version) + .is_ok_and(|versions| versions.matches(target)) + { + Some(format!("要求网关版本 {}", requirements.host_version)) + } else if !host + .manifest_schema_versions + .contains(&requirements.manifest_schema_version) + || !host + .protocol_versions + .contains(&requirements.protocol_version) + || requirements + .capabilities + .iter() + .any(|(capability, version)| { + !host.supports_capability(capability, *version) + }) + { + Some("插件协议或能力与目标版本不兼容".to_owned()) + } else { + None + } + } + } + } else { + match self + .inspector + .inspect(artifact.archive, Some(instance.artifact_sha256.clone())) + .await + { + Ok(_) => None, + Err(error) if error.kind() == AdminErrorKind::Invalid => { + Some("插件与当前宿主不兼容".to_owned()) + } + Err(error) => return Err(map_inspection_error(error)), + } + }; + inspected.insert(instance.artifact_sha256.clone(), reason.clone()); + reason + }; + if let Some(reason) = reason { + plan.incompatible_plugins.push(SystemIncompatiblePlugin { + instance_id: instance.id.clone(), + name: instance.name.clone(), + reason, + }); + } + } + plan.incompatible_plugins + .sort_by(|a, b| a.instance_id.cmp(&b.instance_id)); + Ok(plan) + } +} + +pub(crate) struct ConfirmedPluginRestart { + pub preflight: Arc, + pub confirmation: Option, + pub context: MutationContext, +} + +#[async_trait] +impl SystemRestartPreflight for ConfirmedPluginRestart { + async fn prepare( + &self, + candidate: Option, + ) -> Result<(), SystemOperationError> { + let plan = self.preflight.plan(candidate).await?; + if self + .confirmation + .as_ref() + .is_some_and(|confirmed| confirmed != &plan) + { + return Err(conflict("插件配置或目标版本已变化,请重新检查并确认重启")); + } + if !plan.incompatible_plugins.is_empty() && self.confirmation.is_none() { + return Err(conflict("请先确认停用不兼容插件,再重启服务")); + } + let mut revision = + Revision::new(plan.config_revision).map_err(|_| internal("插件配置版本不合法"))?; + if !plan.incompatible_plugins.is_empty() { + let ids = plan + .incompatible_plugins + .iter() + .map(|item| item.instance_id.clone()) + .collect::>(); + revision = self + .preflight + .store + .disable_instances(&ids, revision, &self.context) + .await + .map_err(map_store_error)?; + publish_committed(self.preflight.snapshots.as_ref(), revision) + .await + .map_err(|_| internal("插件已停用,但运行配置尚未发布,请重试重启"))?; + } + self.preflight.confirm_revision(revision).await + } +} diff --git a/backend/crates/gateway-admin/src/use_case/plugins/artifacts.rs b/backend/crates/gateway-admin/src/use_case/plugins/artifacts.rs index b579e6dda..a33f5e6a5 100644 --- a/backend/crates/gateway-admin/src/use_case/plugins/artifacts.rs +++ b/backend/crates/gateway-admin/src/use_case/plugins/artifacts.rs @@ -1,4 +1,7 @@ -use std::{collections::BTreeSet, sync::Arc}; +use std::{ + collections::{BTreeMap, BTreeSet}, + sync::Arc, +}; use gateway_core::runtime::SnapshotControl; use sha2::{Digest as _, Sha256}; @@ -49,6 +52,7 @@ pub struct PluginsService { pub(super) preparation: Arc, pub(super) published: gateway_core::runtime::RuntimeSnapshotHandle, pub(super) state: PluginStateService, + pub(super) compatibility: tokio::sync::Mutex>>, } impl PluginsService { @@ -70,6 +74,7 @@ impl PluginsService { preparation, published, state: PluginStateService::new(state), + compatibility: tokio::sync::Mutex::new(BTreeMap::new()), } } diff --git a/backend/crates/gateway-admin/src/use_case/plugins/instances.rs b/backend/crates/gateway-admin/src/use_case/plugins/instances.rs index 983e133b9..19e440907 100644 --- a/backend/crates/gateway-admin/src/use_case/plugins/instances.rs +++ b/backend/crates/gateway-admin/src/use_case/plugins/instances.rs @@ -6,7 +6,7 @@ use secrecy::ExposeSecret; use super::PluginsService; use crate::{ model::{ - AdminError, MutationContext, Revision, + AdminError, AdminErrorKind, MutationContext, Revision, plugins::instances::{ ConfigurePluginInstance, PluginInstance, PluginInstanceMutation, PluginInstanceReplacement, PluginInstanceRuntime, PluginInstanceRuntimeStatus, @@ -18,6 +18,36 @@ use crate::{ }; impl PluginsService { + /// 只校验固定包体,不启动插件;停用的旧版本仍需向管理端说明不能启用的原因。 + async fn compatibility_warning(&self, digest: &str) -> Result, AdminError> { + // 运行宿主和摘要固定,缓存纯静态结论,避免管理页轮询反复解包。 + // 只缓存有限数量的确定结果,暂时性错误仍可重试。 + let mut cache = self.compatibility.lock().await; + if let Some(warning) = cache.get(digest) { + return Ok(warning.clone()); + } + let artifact = self + .store + .load_artifact(digest) + .await + .map_err(|error| map_store_error(error, "plugin"))?; + let warning = match self + .inspector + .inspect(artifact.archive, Some(digest.to_owned())) + .await + { + Ok(_) => None, + Err(error) if error.kind() == AdminErrorKind::Invalid => { + Some(error.message().to_owned()) + } + Err(error) => return Err(error), + }; + if cache.len() < 128 { + cache.insert(digest.to_owned(), warning.clone()); + } + Ok(warning) + } + pub async fn instances(&self) -> Result, AdminError> { let snapshot = self .store @@ -52,10 +82,14 @@ impl PluginsService { let metadata = artifacts .get(&instance.artifact_sha256) .ok_or_else(|| AdminError::not_found("插件制品不存在"))?; - let configuration_required = !self - .preparation - .configuration_ready(instance.clone(), metadata) + let compatibility_warning = self + .compatibility_warning(&instance.artifact_sha256) .await?; + let configuration_required = compatibility_warning.is_none() + && !self + .preparation + .configuration_ready(instance.clone(), metadata) + .await?; let runtime = diagnostics.remove(&instance.id).unwrap_or_else(|| { let running = instance.enabled && published_ready @@ -76,6 +110,7 @@ impl PluginsService { }); views.push(PluginInstanceView { configuration_required, + compatibility_warning, running: runtime.status == PluginInstanceRuntimeStatus::Running, published_revision, runtime, @@ -162,6 +197,13 @@ impl PluginsService { if artifact.accepted_at.is_none() { return Err(AdminError::invalid("请先安装并接受该版本声明的权限")); } + if input.enabled + && let Some(warning) = self.compatibility_warning(&input.artifact_sha256).await? + { + return Err(AdminError::invalid(format!( + "{warning},无法启动,请安装兼容版本" + ))); + } let same_plugin = |digest: &str| { artifacts.iter().any(|item| { item.metadata.sha256 == digest diff --git a/backend/crates/gateway-admin/src/use_case/plugins/official.rs b/backend/crates/gateway-admin/src/use_case/plugins/official.rs index 1af450186..14e734d49 100644 --- a/backend/crates/gateway-admin/src/use_case/plugins/official.rs +++ b/backend/crates/gateway-admin/src/use_case/plugins/official.rs @@ -34,7 +34,7 @@ struct SealedReleaseManifest { sealed: bool, gateway_version: String, gateway_git_sha: String, - plugin_host: PluginHostCompatibility, + plugin_host: serde_json::Value, plugins: Vec, } @@ -150,6 +150,7 @@ fn validate_manifest( identity: &OfficialPluginReleaseIdentity, ) -> Result { let manifest = parse_manifest(bytes)?; + parse_host_compatibility(manifest.plugin_host)?; if manifest.gateway_version != identity.gateway_version || manifest.gateway_git_sha != identity.gateway_git_sha { @@ -168,8 +169,39 @@ pub(crate) fn update_compatibility( target_version: &str, ) -> Result { let manifest = parse_manifest(bytes)?; - (manifest.gateway_version == target_version) - .then_some(manifest.plugin_host) + if manifest.gateway_version != target_version { + return Err(invalid_manifest()); + } + parse_host_compatibility(manifest.plugin_host) +} + +pub(crate) fn validate_update_release( + bytes: &[u8], + target_version: &str, +) -> Result<(), AdminError> { + let manifest = parse_manifest(bytes)?; + if manifest.gateway_version != target_version { + return Err(invalid_manifest()); + } + Ok(()) +} + +fn parse_host_compatibility( + mut value: serde_json::Value, +) -> Result { + // 旧更新器要求 v1 与空权限字段,运行兼容性仍由清单、协议和能力版本表达。 + if value.get("schema_version") == Some(&serde_json::json!(1)) + && value.get("permissions") == Some(&serde_json::json!([])) + { + let fields = value.as_object_mut().ok_or_else(invalid_manifest)?; + fields.remove("permissions"); + fields.insert("schema_version".into(), serde_json::json!(2)); + } + let compatibility: PluginHostCompatibility = + serde_json::from_value(value).map_err(|_| invalid_manifest())?; + compatibility + .is_valid() + .then_some(compatibility) .ok_or_else(invalid_manifest) } @@ -183,7 +215,6 @@ fn parse_manifest(bytes: &[u8]) -> Result { || !manifest.sealed || !valid_text(&manifest.gateway_version, MAXIMUM_VERSION_BYTES) || !valid_git_sha(&manifest.gateway_git_sha) - || !manifest.plugin_host.is_valid() || manifest.plugins.len() > MAXIMUM_PLUGINS { return Err(invalid_manifest()); diff --git a/backend/crates/gateway-admin/src/use_case/system.rs b/backend/crates/gateway-admin/src/use_case/system.rs index 3e66a812d..bf0badfe9 100644 --- a/backend/crates/gateway-admin/src/use_case/system.rs +++ b/backend/crates/gateway-admin/src/use_case/system.rs @@ -2,6 +2,8 @@ use std::sync::Arc; +use super::plugin_update::{ConfirmedPluginRestart, PluginSystemUpdatePreflight}; +use crate::model::{MutationContext, system::SystemRestartPlan}; use async_trait::async_trait; use crate::{ @@ -14,7 +16,6 @@ use crate::{ }, ports::system::{ SystemOperationError, SystemOperationErrorKind, SystemOperations, SystemUpdateEventStream, - SystemUpdatePreflight, }, }; @@ -35,20 +36,25 @@ pub trait SystemService: Send + Sync { ) -> Result; async fn update_status(&self) -> Result; async fn rollback(&self) -> Result; - async fn restart(&self) -> Result; + async fn restart_plan(&self) -> Result; + async fn restart( + &self, + confirmation: Option, + context: &MutationContext, + ) -> Result; } /// 保持 Host 能力窄边界的默认系统用例。 pub(crate) struct DefaultSystemService { operations: Arc, - preflight: Arc, + preflight: Arc, } impl DefaultSystemService { #[must_use] pub(crate) fn new( operations: Arc, - preflight: Arc, + preflight: Arc, ) -> Self { Self { operations, @@ -87,7 +93,7 @@ impl SystemService for DefaultSystemService { .map(|version| version.trim().to_owned()) .filter(|version| !version.is_empty()); self.operations - .perform_update(target_version, channel, Arc::clone(&self.preflight)) + .perform_update(target_version, channel, self.preflight.clone()) .await .map_err(map_system_error) } @@ -101,13 +107,36 @@ impl SystemService for DefaultSystemService { async fn rollback(&self) -> Result { self.operations - .rollback(Arc::clone(&self.preflight)) + .rollback(self.preflight.clone()) .await .map_err(map_system_error) } - async fn restart(&self) -> Result { - self.operations.restart().await.map_err(map_system_error) + async fn restart_plan(&self) -> Result { + let candidate = self + .operations + .restart_candidate() + .await + .map_err(map_system_error)?; + self.preflight + .plan(candidate) + .await + .map_err(map_system_error) + } + + async fn restart( + &self, + confirmation: Option, + context: &MutationContext, + ) -> Result { + self.operations + .restart(Arc::new(ConfirmedPluginRestart { + preflight: self.preflight.clone(), + confirmation, + context: context.clone(), + })) + .await + .map_err(map_system_error) } } @@ -120,7 +149,7 @@ fn map_system_error(error: SystemOperationError) -> AdminError { }; let message = match kind { AdminErrorKind::Invalid => "系统操作请求不合法", - AdminErrorKind::Conflict => "系统当前状态不允许执行该操作", + AdminErrorKind::Conflict => error.message(), AdminErrorKind::BadGateway => "系统更新服务请求失败", AdminErrorKind::Internal => "系统操作失败", _ => "系统操作失败", diff --git a/backend/crates/gateway-admin/tests/use_case/mod.rs b/backend/crates/gateway-admin/tests/use_case/mod.rs index 84c051efe..25fc4c382 100644 --- a/backend/crates/gateway-admin/tests/use_case/mod.rs +++ b/backend/crates/gateway-admin/tests/use_case/mod.rs @@ -1086,7 +1086,10 @@ impl SystemOperations for UnavailableSystem { Err(unavailable_system()) } - async fn restart(&self) -> Result { + async fn restart( + &self, + _preflight: Arc, + ) -> Result { Err(unavailable_system()) } } diff --git a/backend/crates/gateway-admin/tests/use_case/plugin_update.rs b/backend/crates/gateway-admin/tests/use_case/plugin_update.rs index 8529c3cf4..70ad8a1e6 100644 --- a/backend/crates/gateway-admin/tests/use_case/plugin_update.rs +++ b/backend/crates/gateway-admin/tests/use_case/plugin_update.rs @@ -28,6 +28,8 @@ use gateway_admin::{ struct Fixture { revisions: std::sync::Mutex>, requirements: PluginCompatibilityRequirements, + instances: Vec, + disabled: std::sync::Mutex>, } impl Fixture { @@ -40,6 +42,8 @@ impl Fixture { .collect(), ), requirements, + instances: vec![instance(true)], + disabled: std::sync::Mutex::new(Vec::new()), }) } @@ -95,18 +99,17 @@ impl PluginStore for Fixture { async fn load_instances(&self) -> AdminStoreResult { Ok(PluginInstanceSnapshot { config_revision: self.next_revision(), - instances: vec![PluginInstance { - id: "enabled-fixture".into(), - name: "Enabled fixture".into(), - artifact_sha256: "a".repeat(64), - enabled: true, - trusted_process: true, - configuration: serde_json::json!({}), - secrets: BTreeMap::new(), - - bindings: Vec::new(), - revision: Revision::new(1).expect("revision"), - }], + instances: self + .instances + .iter() + .cloned() + .map(|mut instance| { + if self.disabled.lock().unwrap().contains(&instance.id) { + instance.enabled = false; + } + instance + }) + .collect(), }) } @@ -135,6 +138,24 @@ impl PluginStore for Fixture { }) } + async fn disable_instances( + &self, + ids: &[String], + expected: Revision, + _: &MutationContext, + ) -> AdminStoreResult { + let mut revisions = self.revisions.lock().unwrap(); + if revisions[0] != expected { + return Err(gateway_admin::ports::store::AdminStoreError::new( + gateway_admin::ports::store::AdminStoreErrorKind::StaleRevision, + "plugin", + "stale", + )); + } + revisions[0] = Revision::new(expected.get() + 1).unwrap(); + self.disabled.lock().unwrap().extend_from_slice(ids); + Ok(revisions[0]) + } async fn save_instance( &self, _: PluginInstance, @@ -273,13 +294,31 @@ impl SystemOperations for PreflightingSystem { async fn rollback( &self, - _: Arc, + preflight: Arc, ) -> Result { - unreachable!() + let revision = preflight.validate_rollback(self.candidate.clone()).await?; + preflight.confirm_revision(revision).await?; + Ok(SystemOperationAccepted::Rollback { + operation_id: "fixture".into(), + message: "accepted".into(), + need_restart: true, + }) } - async fn restart(&self) -> Result { - unreachable!() + async fn restart_candidate( + &self, + ) -> Result, SystemOperationError> { + Ok(Some(self.candidate.clone())) + } + async fn restart( + &self, + preflight: Arc, + ) -> Result { + preflight.prepare(Some(self.candidate.clone())).await?; + Ok(SystemOperationAccepted::Restart { + operation_id: "fixture".into(), + message: "accepted".into(), + }) } } @@ -302,7 +341,7 @@ async fn system_update_accepts_compatible_enabled_plugins_at_the_same_revision() } #[tokio::test] -async fn system_update_rejects_missing_target_capability_and_revision_changes() { +async fn rollback_rejects_missing_target_capability() { let incompatible = Fixture::new(requirements("^1.0", "executor"), &[7]); let services = super::AdminHarness::new() .plugins(incompatible.clone(), incompatible) @@ -311,14 +350,11 @@ async fn system_update_rejects_missing_target_capability_and_revision_changes() })) .build() .await; - assert!( - services - .system() - .perform_update(Some("1.2.0".into()), None) - .await - .is_err() - ); + assert!(services.system().rollback().await.is_err()); +} +#[tokio::test] +async fn system_update_rejects_revision_changes() { let stale = Fixture::new(requirements("^1.0", "executor"), &[7, 8]); let services = super::AdminHarness::new() .plugins(stale.clone(), stale) @@ -336,6 +372,97 @@ async fn system_update_rejects_missing_target_capability_and_revision_changes() ); } +#[tokio::test] +async fn system_update_accepts_new_plugin_contracts_for_empty_disabled_and_enabled_plugins() { + for instances in [vec![], vec![instance(false)], vec![instance(true)]] { + let mut fixture = Fixture::new(requirements("^1.0", "executor"), &[7]); + Arc::get_mut(&mut fixture) + .expect("exclusive fixture") + .instances = instances; + let mut candidate = candidate("models"); + let mut manifest: serde_json::Value = + serde_json::from_slice(&candidate.release_manifest).unwrap(); + manifest["plugin_host"] = + serde_json::json!({"schema_version": 99, "future_contract": true}); + candidate.release_manifest = serde_json::to_vec(&manifest).unwrap().into(); + let services = super::AdminHarness::new() + .plugins(fixture.clone(), fixture) + .system(Arc::new(PreflightingSystem { candidate })) + .build() + .await; + services + .system() + .perform_update(Some("1.2.0".into()), None) + .await + .expect("plugin confirmation is deferred until restart"); + } +} + +#[tokio::test] +async fn system_update_keeps_release_identity_validation_without_plugins() { + for (field, value) in [ + ("gateway_version", serde_json::json!("1.3.0")), + ("gateway_git_sha", serde_json::json!("invalid")), + ("sealed", serde_json::json!(false)), + ("schema_version", serde_json::json!(99)), + ] { + let mut fixture = Fixture::new(requirements("^1.0", "executor"), &[7]); + Arc::get_mut(&mut fixture).unwrap().instances.clear(); + let mut candidate = candidate("executor"); + let mut manifest: serde_json::Value = + serde_json::from_slice(&candidate.release_manifest).unwrap(); + manifest[field] = value; + candidate.release_manifest = serde_json::to_vec(&manifest).unwrap().into(); + let services = super::AdminHarness::new() + .plugins(fixture.clone(), fixture) + .system(Arc::new(PreflightingSystem { candidate })) + .build() + .await; + assert!( + services + .system() + .perform_update(Some("1.2.0".into()), None) + .await + .is_err(), + "{field}" + ); + } +} + +#[tokio::test] +async fn system_update_still_rechecks_revision_without_plugins() { + let mut fixture = Fixture::new(requirements("^1.0", "executor"), &[7, 8]); + Arc::get_mut(&mut fixture).unwrap().instances.clear(); + let services = super::AdminHarness::new() + .plugins(fixture.clone(), fixture) + .system(Arc::new(PreflightingSystem { + candidate: candidate("executor"), + })) + .build() + .await; + assert!( + services + .system() + .perform_update(Some("1.2.0".into()), None) + .await + .is_err() + ); +} + +fn instance(enabled: bool) -> PluginInstance { + PluginInstance { + id: "enabled-fixture".into(), + name: "Enabled fixture".into(), + artifact_sha256: "a".repeat(64), + enabled, + trusted_process: true, + configuration: serde_json::json!({}), + secrets: BTreeMap::new(), + bindings: Vec::new(), + revision: Revision::new(1).expect("revision"), + } +} + fn requirements(host_version: &str, capability: &str) -> PluginCompatibilityRequirements { PluginCompatibilityRequirements { host_version: host_version.into(), @@ -365,3 +492,106 @@ fn candidate(capability: &str) -> SystemUpdateCandidate { release_manifest: manifest.into(), } } + +fn context() -> MutationContext { + MutationContext { + actor: gateway_admin::model::MutationActor::AdminApiKey, + request_id: "restart-test".into(), + } +} + +#[tokio::test] +async fn incompatible_restart_requires_exact_confirmation_before_disabling() { + let fixture = Fixture::new(requirements("^1.0", "executor"), &[7]); + let services = super::AdminHarness::new() + .plugins(fixture.clone(), fixture.clone()) + .system(Arc::new(PreflightingSystem { + candidate: candidate("models"), + })) + .build() + .await; + let plan = services.system().restart_plan().await.unwrap(); + assert_eq!(plan.incompatible_plugins.len(), 1); + assert_eq!(plan.incompatible_plugins[0].name, "Enabled fixture"); + assert!( + fixture.disabled.lock().unwrap().is_empty(), + "检查及取消不能停用插件" + ); + assert!(services.system().restart(None, &context()).await.is_err()); + for altered in 0..3 { + let mut changed = plan.clone(); + match altered { + 0 => changed.config_revision += 1, + 1 => changed.release_manifest_sha256 = Some("b".repeat(64)), + _ => changed.incompatible_plugins.clear(), + } + assert!( + services + .system() + .restart(Some(changed), &context()) + .await + .is_err() + ); + assert!(fixture.disabled.lock().unwrap().is_empty()); + } + services + .system() + .restart(Some(plan), &context()) + .await + .unwrap(); + assert_eq!(*fixture.disabled.lock().unwrap(), ["enabled-fixture"]); + assert!( + services + .system() + .restart_plan() + .await + .unwrap() + .incompatible_plugins + .is_empty() + ); +} + +#[tokio::test] +async fn compatible_restart_does_not_change_plugin_configuration() { + let fixture = Fixture::new(requirements("^1.0", "executor"), &[7]); + let services = super::AdminHarness::new() + .plugins(fixture.clone(), fixture.clone()) + .system(Arc::new(PreflightingSystem { + candidate: candidate("executor"), + })) + .build() + .await; + assert!( + services + .system() + .restart_plan() + .await + .unwrap() + .incompatible_plugins + .is_empty() + ); + services.system().restart(None, &context()).await.unwrap(); + assert!(fixture.disabled.lock().unwrap().is_empty()); + assert_eq!(fixture.next_revision().get(), 7); +} + +#[tokio::test] +async fn changed_configuration_after_restart_check_requires_a_new_confirmation() { + let fixture = Fixture::new(requirements("^1.0", "executor"), &[7, 8]); + let services = super::AdminHarness::new() + .plugins(fixture.clone(), fixture.clone()) + .system(Arc::new(PreflightingSystem { + candidate: candidate("models"), + })) + .build() + .await; + let plan = services.system().restart_plan().await.unwrap(); + assert!( + services + .system() + .restart(Some(plan), &context()) + .await + .is_err() + ); + assert!(fixture.disabled.lock().unwrap().is_empty()); +} diff --git a/backend/crates/gateway-admin/tests/use_case/plugins/artifacts.rs b/backend/crates/gateway-admin/tests/use_case/plugins/artifacts.rs index 2af6e4e0b..b145f97f1 100644 --- a/backend/crates/gateway-admin/tests/use_case/plugins/artifacts.rs +++ b/backend/crates/gateway-admin/tests/use_case/plugins/artifacts.rs @@ -70,7 +70,7 @@ impl Fixture { let unused = Arc::new(TestPluginPorts); PluginsService::new( self.clone(), - unused.clone(), + self.clone(), PluginDistributionPorts::new(unused.clone(), unused.clone()), self.clone(), self.clone(), @@ -87,6 +87,20 @@ impl SnapshotControl for Fixture { } } +#[async_trait] +impl gateway_admin::ports::plugins::PluginPackageInspector for Fixture { + async fn inspect( + &self, + archive: Arc<[u8]>, + _: Option, + ) -> Result { + Ok(InspectedPluginArtifact { + metadata: self.data.lock().unwrap().artifact.metadata.clone(), + archive, + }) + } +} + struct Lease; impl ExtensionSetLease for Lease { @@ -260,7 +274,10 @@ impl PluginStore for Fixture { } async fn load_artifact(&self, _: &str) -> AdminStoreResult { - Err(store_error(AdminStoreErrorKind::Unavailable)) + Ok(InspectedPluginArtifact { + metadata: self.data.lock().unwrap().artifact.metadata.clone(), + archive: Arc::from([1_u8]), + }) } async fn install_artifact( diff --git a/backend/crates/gateway-admin/tests/use_case/plugins/instances.rs b/backend/crates/gateway-admin/tests/use_case/plugins/instances.rs index f00bf6dd2..83bde5d31 100644 --- a/backend/crates/gateway-admin/tests/use_case/plugins/instances.rs +++ b/backend/crates/gateway-admin/tests/use_case/plugins/instances.rs @@ -59,6 +59,28 @@ enum MigrationBehavior { ConcurrentChangeThenFail, } +#[async_trait] +impl gateway_admin::ports::plugins::PluginPackageInspector for LifecycleFixture { + async fn inspect( + &self, + archive: Arc<[u8]>, + digest: Option, + ) -> Result { + let data = self.data.lock().unwrap(); + if let Some(kind) = data.inspection_error { + return Err(AdminError::new(kind, "插件与当前宿主不兼容")); + } + let metadata = data + .artifacts + .iter() + .find(|item| Some(&item.metadata.sha256) == digest.as_ref()) + .unwrap() + .metadata + .clone(); + Ok(InspectedPluginArtifact { metadata, archive }) + } +} + struct SavedInstance { artifact_sha256: String, enabled: bool, @@ -69,6 +91,8 @@ struct FixtureData { snapshot: PluginInstanceSnapshot, artifacts: Vec, artifact_reads: usize, + archive_reads: usize, + inspection_error: Option, change_during_artifact_read: bool, fail_preparation: bool, configuration_ready: bool, @@ -112,6 +136,8 @@ impl LifecycleFixture { artifact(NEW_ARTIFACT, "test.lifecycle", "1.0.0"), ], artifact_reads: 0, + archive_reads: 0, + inspection_error: None, change_during_artifact_read: false, fail_preparation: false, configuration_ready: true, @@ -512,8 +538,20 @@ impl PluginStore for LifecycleFixture { Ok(data.artifacts.clone()) } - async fn load_artifact(&self, _: &str) -> AdminStoreResult { - Err(admin_error(AdminStoreErrorKind::Unavailable)) + async fn load_artifact(&self, digest: &str) -> AdminStoreResult { + let mut data = self.data.lock().unwrap(); + data.archive_reads += 1; + let metadata = data + .artifacts + .iter() + .find(|item| item.metadata.sha256 == digest) + .unwrap() + .metadata + .clone(); + Ok(InspectedPluginArtifact { + metadata, + archive: Arc::from([1_u8]), + }) } async fn install_artifact( @@ -553,7 +591,7 @@ impl SnapshotControl for Published { fn service(fixture: Arc, published: Arc) -> PluginsService { PluginsService::new( fixture.clone(), - Arc::new(TestPluginPorts), + fixture.clone(), PluginDistributionPorts::new(Arc::new(TestPluginPorts), Arc::new(TestPluginPorts)), published, fixture.clone(), @@ -637,7 +675,7 @@ fn input() -> ConfigurePluginInstance { } #[tokio::test] -async fn instance_list_loads_metadata_once_for_shared_artifacts_without_loading_archives() { +async fn instance_list_checks_shared_artifact_compatibility_once() { let fixture = LifecycleFixture::new(MigrationBehavior::Succeed); let mut second = fixture.snapshot().instances[0].clone(); second.id = "00000000-0000-7000-8000-000000000002".into(); @@ -1452,3 +1490,48 @@ async fn version_plan_preserves_observer_event_scopes_and_disabled_subscriptions assert!(fixture.data.lock().unwrap().saves.is_empty()); } } + +#[tokio::test] +async fn incompatible_disabled_plugin_has_warning_and_cannot_be_enabled() { + let fixture = LifecycleFixture::new(MigrationBehavior::Succeed); + { + let mut data = fixture.data.lock().unwrap(); + data.snapshot.instances[0].enabled = false; + data.inspection_error = Some(AdminErrorKind::Invalid); + } + let service = service(fixture.clone(), Arc::new(Published::default())); + let view = service.instances().await.unwrap().remove(0); + assert!(view.compatibility_warning.is_some()); + assert!(!view.instance.enabled); + let mut request = input(); + request.artifact_sha256 = OLD_ARTIFACT.into(); + let error = service + .configure_instance(Some(&view.instance.id), request, &context()) + .await + .err() + .unwrap(); + assert_eq!(error.kind(), AdminErrorKind::Invalid); + assert!(fixture.data.lock().unwrap().saves.is_empty()); + assert_eq!( + fixture.data.lock().unwrap().archive_reads, + 1, + "固定摘要的静态检查结论可复用" + ); + service.instances().await.unwrap(); + assert_eq!(fixture.data.lock().unwrap().archive_reads, 1); +} + +#[tokio::test] +async fn transient_compatibility_check_failure_can_be_retried() { + let fixture = LifecycleFixture::new(MigrationBehavior::Succeed); + fixture.data.lock().unwrap().inspection_error = Some(AdminErrorKind::Unavailable); + let service = service(fixture.clone(), Arc::new(Published::default())); + assert!(service.instances().await.is_err()); + fixture.data.lock().unwrap().inspection_error = None; + assert!( + service.instances().await.unwrap()[0] + .compatibility_warning + .is_none() + ); + assert_eq!(fixture.data.lock().unwrap().archive_reads, 2); +} diff --git a/backend/crates/gateway-admin/tests/use_case/plugins/official.rs b/backend/crates/gateway-admin/tests/use_case/plugins/official.rs index ec0fead27..7628ea28b 100644 --- a/backend/crates/gateway-admin/tests/use_case/plugins/official.rs +++ b/backend/crates/gateway-admin/tests/use_case/plugins/official.rs @@ -288,6 +288,40 @@ async fn official_release_validates_all_packages_then_uses_builtin_install_path( assert_eq!(fixture.publications.lock().unwrap().as_slice(), &[2]); } +#[tokio::test] +async fn official_release_accepts_legacy_update_envelope_with_current_plugin_contracts() { + let mut fixture = Fixture::new(&[]); + let mut manifest: serde_json::Value = + serde_json::from_slice(fixture.manifest.as_ref().unwrap()).unwrap(); + manifest["plugin_host"]["schema_version"] = serde_json::json!(1); + manifest["plugin_host"]["permissions"] = serde_json::json!([]); + Arc::get_mut(&mut fixture).unwrap().manifest = + Some(serde_json::to_vec(&manifest).unwrap().into()); + let imported = fixture + .service() + .import_official_release(fixture.as_ref(), &identity(), &system_context()) + .await + .unwrap(); + assert_eq!(imported.artifacts, 0); +} + +#[tokio::test] +async fn official_release_still_rejects_an_unknown_host_contract_at_startup() { + let mut fixture = Fixture::new(&[]); + let mut manifest: serde_json::Value = + serde_json::from_slice(fixture.manifest.as_ref().unwrap()).unwrap(); + manifest["plugin_host"]["schema_version"] = serde_json::json!(99); + Arc::get_mut(&mut fixture).unwrap().manifest = + Some(serde_json::to_vec(&manifest).unwrap().into()); + assert!( + fixture + .service() + .import_official_release(fixture.as_ref(), &identity(), &system_context()) + .await + .is_err() + ); +} + #[tokio::test] async fn official_release_never_persists_a_valid_prefix_when_later_validation_fails() { let mut fixture = Fixture::new(&[("test.official-1", 1), ("test.official-2", 2)]); diff --git a/backend/crates/gateway-admin/tests/use_case/system.rs b/backend/crates/gateway-admin/tests/use_case/system.rs index 145534647..a181fa919 100644 --- a/backend/crates/gateway-admin/tests/use_case/system.rs +++ b/backend/crates/gateway-admin/tests/use_case/system.rs @@ -106,7 +106,10 @@ impl SystemOperations for RecordingSystemOperations { }) } - async fn restart(&self) -> Result { + async fn restart( + &self, + _preflight: std::sync::Arc, + ) -> Result { Ok(SystemOperationAccepted::Restart { operation_id: "operation-restart".to_owned(), message: "accepted".to_owned(), diff --git a/backend/crates/gateway-api/src/admin/plugins/instances.rs b/backend/crates/gateway-api/src/admin/plugins/instances.rs index e0b78263d..fdeb87083 100644 --- a/backend/crates/gateway-api/src/admin/plugins/instances.rs +++ b/backend/crates/gateway-api/src/admin/plugins/instances.rs @@ -52,6 +52,7 @@ struct InstanceView { artifact_sha256: String, enabled: bool, configuration_required: bool, + compatibility_warning: Option, configuration: serde_json::Value, secret_fields: Vec, bindings: Vec, @@ -146,6 +147,7 @@ impl From for InstanceView { artifact_sha256, enabled, configuration_required: value.configuration_required, + compatibility_warning: value.compatibility_warning, configuration, secret_fields: secrets.into_keys().collect(), bindings, diff --git a/backend/crates/gateway-api/src/admin/system.rs b/backend/crates/gateway-api/src/admin/system.rs index d0940f07b..0a7a0dd4a 100644 --- a/backend/crates/gateway-api/src/admin/system.rs +++ b/backend/crates/gateway-api/src/admin/system.rs @@ -20,8 +20,8 @@ use axum::{ use futures::{Stream, StreamExt}; use gateway_admin::model::system::{ SystemOperationAccepted, SystemOperationKind, SystemOperationState, SystemOperationStatus, - SystemUpdateChannel, SystemUpdateDetail, SystemUpdateEvent, SystemUpdateEventLevel, - SystemUpdatePolicy, SystemUpdateStatus, SystemVersion, + SystemRestartPlan, SystemUpdateChannel, SystemUpdateDetail, SystemUpdateEvent, + SystemUpdateEventLevel, SystemUpdatePolicy, SystemUpdateStatus, SystemVersion, }; use serde::{Deserialize, Serialize}; @@ -114,6 +114,7 @@ struct SystemUpdateDetailView { notes: Option, cached: bool, update_supported: bool, + restart_confirmation_supported: bool, unsupported_reason: Option, warning: Option, } @@ -133,6 +134,7 @@ impl From for SystemUpdateDetailView { notes: detail.notes, cached: detail.cached, update_supported: detail.update_supported, + restart_confirmation_supported: true, unsupported_reason: detail.unsupported_reason, warning: detail.warning, } @@ -239,6 +241,7 @@ where .route("/api/admin/system/update", post(perform_update::)) .route("/api/admin/system/update/status", get(update_status::)) .route("/api/admin/system/rollback", post(rollback::)) + .route("/api/admin/system/restart/check", get(restart_plan::)) .route("/api/admin/system/restart", post(restart::)) } @@ -387,17 +390,43 @@ where )) } -async fn restart( +async fn restart_plan( _auth: AdminAuth, State(state): State, ) -> Result +where + S: SessionState + Send + Sync, +{ + let plan = state + .admin_services() + .system() + .restart_plan() + .await + .map_err(map_system_error)?; + Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(plan))) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RestartRequest { + confirmation: Option, +} + +async fn restart( + auth: AdminAuth, + State(state): State, + payload: Option>, +) -> Result where S: SessionState + Send + Sync, { let result = state .admin_services() .system() - .restart() + .restart( + payload.and_then(|AdminJson(request)| request.confirmation), + &auth.context().mutation_context(), + ) .await .map_err(map_system_error)?; let SystemOperationAccepted::Restart { diff --git a/backend/crates/gateway-api/tests/admin/mod.rs b/backend/crates/gateway-api/tests/admin/mod.rs index 55a5b80f3..0c403dad8 100644 --- a/backend/crates/gateway-api/tests/admin/mod.rs +++ b/backend/crates/gateway-api/tests/admin/mod.rs @@ -1520,7 +1520,10 @@ impl SystemOperations for UnusedSystem { Err(unavailable_system()) } - async fn restart(&self) -> Result { + async fn restart( + &self, + _preflight: Arc, + ) -> Result { Err(unavailable_system()) } } diff --git a/backend/crates/gateway-api/tests/admin/plugins/mod.rs b/backend/crates/gateway-api/tests/admin/plugins/mod.rs index 62fe5e645..377aa0a37 100644 --- a/backend/crates/gateway-api/tests/admin/plugins/mod.rs +++ b/backend/crates/gateway-api/tests/admin/plugins/mod.rs @@ -891,6 +891,12 @@ impl PluginStore for TestPluginPorts { Ok(vec![accepted_artifact()]) } async fn load_artifact(&self, digest: &str) -> AdminStoreResult { + if digest == "a".repeat(64) { + return Ok(InspectedPluginArtifact { + metadata: accepted_artifact().metadata, + archive: Arc::from([1_u8]), + }); + } if matches!(digest.as_bytes().first(), Some(b'e' | b'f')) && digest.len() == 64 { return Ok(icon_artifact(digest)); } @@ -929,7 +935,10 @@ impl PluginPackageInspector for TestPluginPorts { _: Arc<[u8]>, _: Option, ) -> Result { - Err(AdminError::invalid("unused plugin fixture")) + Ok(InspectedPluginArtifact { + metadata: accepted_artifact().metadata, + archive: Arc::from([1_u8]), + }) } async fn icon( diff --git a/backend/crates/gateway-api/tests/admin/system.rs b/backend/crates/gateway-api/tests/admin/system.rs index af1f2f130..ac72e859d 100644 --- a/backend/crates/gateway-api/tests/admin/system.rs +++ b/backend/crates/gateway-api/tests/admin/system.rs @@ -204,7 +204,10 @@ impl SystemOperations for ProgressSystem { Err(unavailable_system()) } - async fn restart(&self) -> Result { + async fn restart( + &self, + _preflight: Arc, + ) -> Result { Err(unavailable_system()) } } diff --git a/backend/crates/gateway-api/tests/support.rs b/backend/crates/gateway-api/tests/support.rs index 9b9a04497..b7051ff1a 100644 --- a/backend/crates/gateway-api/tests/support.rs +++ b/backend/crates/gateway-api/tests/support.rs @@ -133,7 +133,10 @@ impl SystemOperations for VersionSystem { unreachable!("client route must not roll back") } - async fn restart(&self) -> Result { + async fn restart( + &self, + _preflight: Arc, + ) -> Result { unreachable!("client route must not restart") } } diff --git a/backend/crates/gateway-host/src/system_update/mod.rs b/backend/crates/gateway-host/src/system_update/mod.rs index e83530a10..7ec33575a 100644 --- a/backend/crates/gateway-host/src/system_update/mod.rs +++ b/backend/crates/gateway-host/src/system_update/mod.rs @@ -23,8 +23,8 @@ use gateway_admin::model::system::{ SystemUpdateStatus, SystemVersion, }; use gateway_admin::ports::system::{ - SystemOperationError, SystemOperationErrorKind, SystemOperations, SystemUpdateCandidate, - SystemUpdateEventStream, SystemUpdatePreflight, + SystemOperationError, SystemOperationErrorKind, SystemOperations, SystemRestartPreflight, + SystemUpdateCandidate, SystemUpdateEventStream, SystemUpdatePreflight, }; use gateway_core::lifecycle::CancellationToken; use serde::Deserialize; @@ -452,14 +452,14 @@ impl ProcessSystemOperations { self.events.info( Some(operation_id), Some("preflight"), - "正在检查启用插件与目标版本的兼容性", + "正在校验目标发行信息", ); let plugin_revision = preflight.validate(candidate).await?; preflight.confirm_revision(plugin_revision).await?; self.events.success( Some(operation_id), Some("preflight"), - "启用插件兼容性检查通过", + "目标发行信息校验通过,插件兼容性将在重启前检查", ); self.events .info(Some(operation_id), Some("replace"), "正在替换应用文件"); @@ -499,6 +499,23 @@ impl ProcessSystemOperations { Ok(channel) } + fn installed_restart_candidate(&self) -> Result, OperationError> { + if self.config.build_type == "source" { + return Ok(None); + } + let release_manifest = read_release_manifest(&self.config.official_plugins_dir()?)?; + #[derive(Deserialize)] + struct ReleaseIdentity { + gateway_version: String, + } + let identity: ReleaseIdentity = serde_json::from_slice(&release_manifest) + .map_err(|_| invalid("已安装发行清单缺少目标版本"))?; + Ok(Some(SystemUpdateCandidate { + target_version: identity.gateway_version, + release_manifest, + })) + } + fn reconcile_installation(&self) -> Result { let running_files = self.running_files.as_ref().as_ref().map_err(Clone::clone)?; reconcile_installation(&self.config, running_files) @@ -610,7 +627,7 @@ impl SystemOperations for ProcessSystemOperations { let release_manifest = read_release_manifest(&rollback_official_plugins_dir(&self.config)?)?; let plugin_revision = preflight - .validate(SystemUpdateCandidate { + .validate_rollback(SystemUpdateCandidate { target_version, release_manifest: Arc::clone(&release_manifest), }) @@ -671,7 +688,18 @@ impl SystemOperations for ProcessSystemOperations { }) } - async fn restart(&self) -> Result { + async fn restart_candidate(&self) -> Result, OperationError> { + let _operation = self + .operation_lock + .try_lock() + .map_err(|_| conflict("system operation is already running"))?; + self.installed_restart_candidate() + } + + async fn restart( + &self, + preflight: Arc, + ) -> Result { // 与 update/rollback 互斥:更新替换文件期间触发自重启会让新进程 // 载入半成品产物。 let _operation = self @@ -681,6 +709,18 @@ impl SystemOperations for ProcessSystemOperations { if !self.config.self_restart_enabled { return Err(conflict("self restart is disabled")); } + let _file_lock = OperationFileLock::acquire(&self.config.update_lock_file)?; + let candidate = self.installed_restart_candidate()?; + let files = candidate + .as_ref() + .map(|_| ReleaseFiles::installed(&self.config)) + .transpose()?; + preflight.prepare(candidate).await?; + if let Some(files) = files + && files != ReleaseFiles::installed(&self.config)? + { + return Err(conflict("重启检查期间安装文件已变化,请重新确认")); + } let message = if self.config.deployment_mode == "docker" { "已安排进程内重启" } else { diff --git a/backend/crates/gateway-host/tests/system_update/mod.rs b/backend/crates/gateway-host/tests/system_update/mod.rs index 749a4180d..542b5c91a 100644 --- a/backend/crates/gateway-host/tests/system_update/mod.rs +++ b/backend/crates/gateway-host/tests/system_update/mod.rs @@ -32,10 +32,18 @@ use tar::{Builder, EntryType, Header}; use wiremock::matchers::{method, path, query_param}; use wiremock::{Mock, MockServer, ResponseTemplate}; +const OLD_RELEASE_MANIFEST: &str = r#"{"gateway_version":"1.0.0"}"#; + const TARGET_VERSION: &str = "1.9.9"; const CROSS_MAJOR_VERSION: &str = "2.0.0"; struct AllowingUpdatePreflight; +#[async_trait::async_trait] +impl gateway_admin::ports::system::SystemRestartPreflight for AllowingUpdatePreflight { + async fn prepare(&self, _: Option) -> Result<(), SystemOperationError> { + Ok(()) + } +} #[async_trait::async_trait] impl SystemUpdatePreflight for AllowingUpdatePreflight { @@ -107,7 +115,10 @@ impl SystemUpdatePreflight for ChangingRollbackRevisionPreflight { candidate: SystemUpdateCandidate, ) -> Result { assert_eq!(candidate.target_version, "1.0.0"); - assert_eq!(candidate.release_manifest.as_ref(), b"old-manifest"); + assert_eq!( + candidate.release_manifest.as_ref(), + OLD_RELEASE_MANIFEST.as_bytes() + ); Ok(Revision::new(11).expect("revision")) } @@ -135,7 +146,10 @@ impl SystemUpdatePreflight for BlockingRollbackPreflight { candidate: SystemUpdateCandidate, ) -> Result { assert_eq!(candidate.target_version, "1.0.0"); - assert_eq!(candidate.release_manifest.as_ref(), b"old-manifest"); + assert_eq!( + candidate.release_manifest.as_ref(), + OLD_RELEASE_MANIFEST.as_bytes() + ); Ok(Revision::new(13).expect("revision")) } @@ -157,7 +171,12 @@ async fn restart_should_not_shutdown_when_replacement_spawn_fails() { let shutdown = CancellationToken::new(); let service = ProcessSystemOperations::new(shutdown.clone(), config); - assert!(service.restart().await.is_err()); + assert!( + service + .restart(Arc::new(AllowingUpdatePreflight)) + .await + .is_err() + ); assert!(!shutdown.is_cancelled()); } @@ -169,7 +188,10 @@ async fn restart_should_request_process_restart_inside_docker() { config.deployment_mode = "docker".to_owned(); let shutdown = CancellationToken::new(); let service = ProcessSystemOperations::new(shutdown.clone(), config); - service.restart().await.expect("restart accepted"); + service + .restart(Arc::new(AllowingUpdatePreflight)) + .await + .expect("restart accepted"); tokio::time::timeout(Duration::from_secs(2), shutdown.cancelled()) .await @@ -188,7 +210,7 @@ async fn restart_should_spawn_replacement_before_shutdown_outside_docker() { assert_eq!( service - .restart() + .restart(Arc::new(AllowingUpdatePreflight)) .await .expect("replacement scheduled") .kind(), @@ -259,7 +281,10 @@ async fn restart_should_use_startup_path_after_running_executable_is_renamed() { fixture.write_executable("#!/bin/sh\nprintf new > \"${0%/*}/replacement-version\"\n"); assert_eq!(std::env::current_exe().expect("renamed executable"), backup); - service.restart().await.expect("restart accepted"); + service + .restart(Arc::new(AllowingUpdatePreflight)) + .await + .expect("restart accepted"); let marker = fixture.root.path().join("replacement-version"); tokio::time::timeout(Duration::from_secs(5), async { while !marker.is_file() { @@ -308,7 +333,10 @@ async fn restart_should_conflict_while_another_system_operation_is_running() { .expect("update reaches release fetch while holding the operation lock") .expect("connected signal"); - let error = service.restart().await.expect_err("restart during update"); + let error = service + .restart(Arc::new(AllowingUpdatePreflight)) + .await + .expect_err("restart during update"); assert_eq!(error.kind(), SystemOperationErrorKind::Conflict); assert!(!shutdown.is_cancelled()); @@ -319,7 +347,10 @@ async fn restart_should_conflict_while_another_system_operation_is_running() { wait_for_update(&service).await.operation.status, SystemOperationStatus::Failed ); - service.restart().await.expect("restart after lock release"); + service + .restart(Arc::new(AllowingUpdatePreflight)) + .await + .expect("restart after lock release"); } #[tokio::test] @@ -375,7 +406,7 @@ async fn rollback_should_restore_binary_web_and_version_state() { ); assert_eq!( fs::read(fixture.official().join("plugin-release-manifest.json")).expect("manifest"), - b"old-manifest" + OLD_RELEASE_MANIFEST.as_bytes() ); assert_eq!( fs::read(fixture.official().join("old-plugin.tar.gz")).expect("old plugin"), @@ -441,7 +472,7 @@ async fn update_should_restore_all_files_when_plugin_revision_changes_during_swa ); assert_eq!( fs::read(fixture.official().join("plugin-release-manifest.json")).expect("manifest"), - b"old-manifest" + OLD_RELEASE_MANIFEST.as_bytes() ); assert!(fixture.official().join("old-plugin.tar.gz").is_file()); assert!(!fixture.official().join("new-plugin.tar.gz").exists()); @@ -1651,7 +1682,7 @@ async fn update_should_restore_web_assets_when_binary_backup_fails() { ); assert_eq!( fs::read(fixture.official().join("plugin-release-manifest.json")).expect("manifest"), - b"old-manifest" + OLD_RELEASE_MANIFEST.as_bytes() ); assert!(fixture.official().join("old-plugin.tar.gz").is_file()); assert!(!fixture.official().join("new-plugin.tar.gz").exists()); @@ -1963,7 +1994,7 @@ impl Fixture { fs::create_dir_all(fixture.official()).expect("official plugin dir"); fs::write( fixture.official().join("plugin-release-manifest.json"), - "old-manifest", + OLD_RELEASE_MANIFEST, ) .expect("manifest"); fs::write(fixture.official().join("old-plugin.tar.gz"), "old-plugin").expect("plugin"); @@ -2230,3 +2261,43 @@ fn release_response(version: &str, assets: Vec) -> ResponseTe }); ResponseTemplate::new(200).set_body_json(serde_json::json!([release])) } + +struct RejectingRestartPreflight; +#[async_trait::async_trait] +impl gateway_admin::ports::system::SystemRestartPreflight for RejectingRestartPreflight { + async fn prepare( + &self, + candidate: Option, + ) -> Result<(), SystemOperationError> { + assert_eq!(candidate.unwrap().target_version, "1.0.0"); + Err(SystemOperationError::new( + SystemOperationErrorKind::Conflict, + "plugins need confirmation", + )) + } +} + +#[tokio::test] +async fn restart_checks_plugins_before_shutdown_and_releases_locks_on_rejection() { + let fixture = Fixture::new(); + let mut config = fixture.config("http://127.0.0.1:1/repos"); + config.self_restart_enabled = true; + config.deployment_mode = "docker".into(); + let shutdown = CancellationToken::new(); + let service = ProcessSystemOperations::new(shutdown.clone(), config); + assert!( + service + .restart(Arc::new(RejectingRestartPreflight)) + .await + .is_err() + ); + assert!(!shutdown.is_cancelled()); + assert!(service.restart_candidate().await.unwrap().is_some()); + service + .restart(Arc::new(AllowingUpdatePreflight)) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(2), shutdown.cancelled()) + .await + .unwrap(); +} diff --git a/backend/crates/gateway-plugin/runtime/src/package/inspection.rs b/backend/crates/gateway-plugin/runtime/src/package/inspection.rs index d8543a54d..729f0eb11 100644 --- a/backend/crates/gateway-plugin/runtime/src/package/inspection.rs +++ b/backend/crates/gateway-plugin/runtime/src/package/inspection.rs @@ -51,6 +51,9 @@ impl PluginPackageInspector for PackageInspector { let package = ValidatedPackage::read(archive, expected_sha256.as_deref(), limits) .map_err(inspection_error)?; let manifest = package.manifest(); + if !super::compatibility::supports(manifest)? { + return Err(AdminError::invalid("插件能力与当前宿主不兼容")); + } let target = manifest .package_for(&host_version, std::env::consts::OS, std::env::consts::ARCH) .map_err(|_| AdminError::invalid("插件不支持当前网关版本或平台"))?; diff --git a/backend/crates/gateway-plugin/runtime/tests/support/environment.rs b/backend/crates/gateway-plugin/runtime/tests/support/environment.rs index db51ee314..046323836 100644 --- a/backend/crates/gateway-plugin/runtime/tests/support/environment.rs +++ b/backend/crates/gateway-plugin/runtime/tests/support/environment.rs @@ -849,7 +849,10 @@ impl SystemOperations for UnusedAdminRuntime { Err(unused_system()) } - async fn restart(&self) -> Result { + async fn restart( + &self, + _preflight: Arc, + ) -> Result { Err(unused_system()) } } diff --git a/backend/crates/gateway-store/src/postgres/plugins/artifacts.rs b/backend/crates/gateway-store/src/postgres/plugins/artifacts.rs index 09d6705e9..03dd1fcfa 100644 --- a/backend/crates/gateway-store/src/postgres/plugins/artifacts.rs +++ b/backend/crates/gateway-store/src/postgres/plugins/artifacts.rs @@ -96,6 +96,14 @@ impl PluginStore for PgPluginStore { async fn configuration_versions(&self, id: &str) -> AdminStoreResult> { super::instances::configuration_versions(&self.pool, id).await } + async fn disable_instances( + &self, + ids: &[String], + expected: Revision, + context: &MutationContext, + ) -> AdminStoreResult { + super::instances::disable(&self.pool, ids, expected, context).await + } async fn save_instance( &self, instance: gateway_admin::model::plugins::instances::PluginInstance, diff --git a/backend/crates/gateway-store/src/postgres/plugins/instances.rs b/backend/crates/gateway-store/src/postgres/plugins/instances.rs index b17dabbe4..0fd310ecc 100644 --- a/backend/crates/gateway-store/src/postgres/plugins/instances.rs +++ b/backend/crates/gateway-store/src/postgres/plugins/instances.rs @@ -399,3 +399,42 @@ pub(super) async fn delete( fn revision_from_i64(value: i64) -> AdminStoreResult { Revision::new(u64::try_from(value).map_err(|_| unavailable())?).map_err(|_| unavailable()) } + +/// 所有停用共享全局 CAS 和审计事务,不能留下只停用一部分插件的状态。 +pub(super) async fn disable( + pool: &PgPool, + ids: &[String], + expected: Revision, + context: &MutationContext, +) -> AdminStoreResult { + let mut tx = pool.begin().await.map_err(|_| unavailable())?; + check_revision(&mut tx, expected).await?; + if ids.is_empty() { + return Ok(expected); + } + let revision = bump_config_revision_in_transaction(&mut tx) + .await + .map_err(|e| admin_store_error("plugin", e))?; + let committed = admin_revision(revision)?; + for id in ids { + let uuid = uuid::Uuid::parse_str(id).map_err(|_| conflict())?; + let digest: String = sqlx::query_scalar("update plugin_instances set enabled=false,revision=$2 where id=$1 and enabled=true returning artifact_sha256") + .bind(uuid).bind(i64::try_from(revision.get()).map_err(|_| unavailable())?) + .fetch_optional(&mut *tx).await.map_err(|_| unavailable())?.ok_or_else(conflict)?; + super::state::rebind_existing_configuration(&mut tx, id, &digest, committed).await?; + append_admin_audit_event_in_transaction( + &mut tx, + mutation_audit( + context, + MutationAuditOperation::PluginInstanceConfigure, + id, + vec!["enabled".into()], + ), + revision, + ) + .await + .map_err(|e| admin_store_error("plugin", e))?; + } + tx.commit().await.map_err(|_| unavailable())?; + Ok(committed) +} diff --git a/backend/crates/gateway-store/tests/postgres/plugins/instances.rs b/backend/crates/gateway-store/tests/postgres/plugins/instances.rs index 3e0d038d1..0fb5f5649 100644 --- a/backend/crates/gateway-store/tests/postgres/plugins/instances.rs +++ b/backend/crates/gateway-store/tests/postgres/plugins/instances.rs @@ -786,3 +786,86 @@ async fn version_settings_survive_upgrade_and_disabled_edits_and_share_the_trans assert!(store.configuration_versions(&id).await.unwrap().is_empty()); database.close().await; } + +#[tokio::test] +async fn confirmed_disable_is_atomic_and_preserves_plugin_data() { + let Some(database) = TestDatabase::create("confirmed_disable").await else { + return; + }; + initialize_revision(&database).await; + let store = PgPluginStore::new(database.pool.clone()); + let installed = store + .install_artifact( + artifact('a', &["linux-x86_64"]), + PluginSource::Upload, + &context(), + ) + .await + .unwrap(); + let accepted = store + .accept_artifact(&installed.artifact.metadata.sha256, &context()) + .await + .unwrap(); + let mut expected = accepted.config_revision; + let mut ids = Vec::new(); + for name in ["First", "Second", "Compatible"] { + let mut candidate = instance(installed.artifact.metadata.sha256.clone()); + candidate.name = name.into(); + candidate.enabled = true; + let saved = store + .save_instance(candidate, expected, &context()) + .await + .unwrap(); + expected = saved.config_revision; + ids.push(saved.instance.id); + } + let original = store.load_instances().await.unwrap(); + for (targets, revision) in [ + ( + vec![ids[0].clone(), uuid::Uuid::now_v7().to_string()], + expected, + ), + ( + ids[..2].to_vec(), + Revision::new(expected.get() - 1).unwrap(), + ), + ] { + assert!( + store + .disable_instances(&targets, revision, &context()) + .await + .is_err() + ); + let unchanged = store.load_instances().await.unwrap(); + assert_eq!(unchanged.config_revision, expected); + assert!(unchanged.instances.iter().all(|item| item.enabled)); + } + let committed = store + .disable_instances(&ids[..2], expected, &context()) + .await + .unwrap(); + assert_eq!(committed.get(), expected.get() + 1); + let current = store.load_instances().await.unwrap(); + for instance in current.instances { + let before = original + .instances + .iter() + .find(|item| item.id == instance.id) + .unwrap(); + assert_eq!(instance.enabled, instance.id == ids[2]); + assert_eq!(instance.configuration, before.configuration); + assert_eq!(instance.bindings, before.bindings); + assert_eq!( + instance.secrets["token"].expose_secret(), + before.secrets["token"].expose_secret() + ); + assert!( + store + .load_version_configuration(&instance.id, &instance.artifact_sha256) + .await + .unwrap() + .is_some() + ); + } + database.close().await; +} diff --git a/deploy/README.md b/deploy/README.md index 12178a77b..17302b293 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -573,7 +573,8 @@ Release 必须提供当前 OS/架构的 `codex-proxy-rs___.ta | `plugin-release-manifest.json` | 固定宿主版本、提交、平台与支持的插件合同;插件列表当前为空 | | 可执行文件同目录的 `plugins/official` | 随受信宿主发行物部署的只读目录,与二进制、Web 资源成套更新或恢复 | -- 更新或回滚前,逐一检查启用实例的精确包与目标宿主是否兼容。制品缺失、不兼容或并发配置变更会阻止切换;失败或取消时恢复整组文件,不自动停用插件或接受其他制品 +- 下载更新时校验发行身份,插件合同不兼容不阻止安装。点击重启时检查已安装目标版本,列出不兼容插件的名称和原因;确认后批量停用并重启,保留包体、配置、密钥和私有数据,取消不修改插件状态 +- 回滚仍检查启用插件与旧宿主的兼容性,不兼容时先停用对应插件。更新或回滚期间的并发配置变更会阻止切换,文件替换失败或取消时恢复整组文件 - 非空官方清单中的包经身份、平台和摘要校验后幂等导入;导入不确认信任、不创建配置、不启用,也不删除旧包。管理员确认信任后才执行默认配置流程;重复摘要保留首次安装出处 - `sealed` 是构建封口标记,不是密码学签名。普通上传、URL 或 GitHub 安装不能获得 `builtin` 身份,独立教学示例也不例外 diff --git a/docs/api.md b/docs/api.md index 94b294353..645d03834 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1652,7 +1652,8 @@ Provider metadata 分别保留 `requestedServiceTier` 与 `upstreamServiceTier` | `POST` | `/api/admin/system/update` | `{ targetVersion, channel? }` | 受理后台在线更新,返回 `202` | | `GET` | `/api/admin/system/update/status` | 无 | 查询当前更新或回滚状态 | | `POST` | `/api/admin/system/rollback` | 无 | 回滚到保留的上一版本 | -| `POST` | `/api/admin/system/restart` | 无 | 请求进程重启 | +| `GET` | `/api/admin/system/restart/check` | 无 | 只读检查重启目标与启用插件 | +| `POST` | `/api/admin/system/restart` | `{ confirmation? }` | 复核确认、停用不兼容插件并重启 | 在线更新遵循[版本命名与升级规则](../deploy/README.md#版本命名与升级规则)。版本接口的 `updateChannel` 由当前版本推导,取值为 `stable`、`alpha`、`beta`、`rc`、`exp`,无法识别时为 `unknown`。 @@ -1675,10 +1676,15 @@ Provider metadata 分别保留 `requestedServiceTier` 与 `upstreamServiceTier` 执行,结果通过 `/update/status` 的 `operation` 查询:`status` 为 `idle`、`running`、`succeeded` 或 `failed`, 终态包含 `finishedAt`,失败原因在 `error` 中。SSE 的 `operationId` 用于关联进度;终态事件发出前状态已落盘。 连接中断不取消已受理任务;响应丢失时先查询状态,不自动重复提交。打开更新页面时也会恢复最近一次任务。 -下载并解包后,Admin 会以目标发行清单声明的宿主插件合同检查每个启用实例的精确制品;回滚则对备份发行清单 -执行同一检查。两条路径都会在文件交换前后复核同一全局插件配置版本。任一实例不兼容、制品缺失、请求取消或 -配置并发变化都会失败;已交换的二进制、Web 资源和官方插件目录会成组恢复。预检不会自动停用实例、切换插件 -版本或接受其他制品 +下载并解包后校验发行身份,插件不兼容不阻止安装;回滚仍要求启用插件与备份发行兼容。 +两条路径在文件交换前后复核全局配置版本,文件替换失败或取消时成组恢复二进制、Web 资源和官方插件目录 + +详情响应的 `restartConfirmationSupported=true` 表示运行进程支持重启前确认。 +`restart/check` 返回 `targetVersion`、`releaseManifestSha256`、`configRevision` 和 `incompatiblePlugins`, +每项包含 `instanceId`、`name`、`reason`,检查不修改插件状态。源码运行的目标版本与发行摘要为空,检查当前宿主兼容性。 +存在不兼容插件时,客户端展示列表并取得确认后,将完整检查结果作为 `confirmation` 提交重启请求。 +服务端在重启锁内重新检查,目标或配置变化返回 `40901`,须重新检查并确认;确认匹配才在单个事务中停用对应插件并发布配置。 +没有不兼容插件时可省略确认,不切换插件版本或接受其他制品。停用保留设置、密钥、版本配置和私有数据 状态响应的 `currentVersion` 表示已安装文件的版本,运行中的版本仍以 `/version` 为准。 `needRestart=true` 表示已验证的安装文件尚未在当前进程生效,此时应调用重启接口,不能重复发起更新或切换通道。 @@ -1868,7 +1874,8 @@ GitHub 的 `location` 使用 `kind: "github"`、`repository: "owner/repo"`、`ta 待停用配置必须仍启用且 revision 与确认时一致,否则返回 409,重新读取并确认后再提交 列表项包含 `id`、`name`、`artifactSha256`、`enabled`、`configurationRequired`、`configuration`、 -`secretFields`、`bindings`、`revision`、`running`、`publishedRevision` 和 `runtime`。`configurationRequired` +`secretFields`、`bindings`、`revision`、`running`、`publishedRevision`、`runtime` 和 `compatibilityWarning`。 +`compatibilityWarning` 为非空字符串时表示该固定制品无法在当前宿主启动,停用实例也返回原因,启用请求会拒绝。`configurationRequired` 由当前制品 schema 与已保存的普通/敏感配置实时派生,不写入数据库;它只表示仍缺少必填值。停用实例也不能保存 类型错误、非法 schema 或把敏感字段混入普通配置 diff --git a/docs/architecture.md b/docs/architecture.md index 719d03c29..89a4ad9e9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -794,8 +794,10 @@ worker 复用连接测试探针执行真实上游调用,并按冻结代次处 ### 在线更新与回滚 -在线更新校验 Release host、大小、SHA-256 和归档路径,并只允许同一大版本内更新;更新与回滚的目标发行清单 -还要通过全部启用插件的静态兼容检查,文件交换前后复核同一插件配置版本,失败或取消时恢复二进制、Web 与官方插件目录 +在线更新校验 Release host、大小、SHA-256、归档路径和发行身份,并只允许同一大版本内更新。 +下载阶段不以插件合同不兼容阻止安装。重启前由 Admin 检查已安装发行与启用插件,返回绑定发行摘要和配置版本的确认快照。 +Host 在重启操作锁内重新检查,Admin 仅在确认匹配时原子停用对应插件并发布配置,保留密钥和私有数据;取消不产生写入。 +回滚仍要求全部启用插件与旧宿主兼容;更新与回滚在文件交换前后复核同一插件配置版本,失败或取消时恢复二进制、Web 与官方插件目录 Host 在受理时持久化任务并转交后台执行,任务持有操作锁与终态写入责任,不依赖 HTTP 请求的生命周期。 状态文件记录操作结果,SSE 终态在状态落盘后发送,Host 关闭与任务析构都必须收敛状态 diff --git a/docs/plugins.md b/docs/plugins.md index 9d559a940..ac083316e 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -117,7 +117,9 @@ flowchart LR ### 故障处理 -宿主重启时逐个恢复已启用插件。不兼容、启动失败的实例显示异常并保留设置,管理员可重新启动、停用或切换版本; +系统更新后的重启检查会列出不兼容插件,管理员确认后停用并重启,保留设置、密钥和私有数据。 +插件管理页用黄色警告三角显示不兼容原因,此类插件不能启动,安装兼容版本后可重新启用。 +其他启动失败的实例显示异常并保留设置,管理员可重新启动、停用或切换版本; 其他插件、原生转发和管理端继续运行。运行中的插件进程退出时,也不会撤销整个宿主的运行配置。 命中故障插件绑定的请求仍遵循既有拒绝或委托策略;入口认证插件失败不会绕过认证 diff --git a/frontend/src/api/modules/plugins.ts b/frontend/src/api/modules/plugins.ts index 231be7ff2..e48c8140d 100644 --- a/frontend/src/api/modules/plugins.ts +++ b/frontend/src/api/modules/plugins.ts @@ -216,6 +216,7 @@ export interface PluginInstance { artifactSha256: string enabled: boolean configurationRequired: boolean + compatibilityWarning: string | null configuration: Record secretFields: string[] bindings: PluginCapabilityBinding[] diff --git a/frontend/src/api/modules/system.ts b/frontend/src/api/modules/system.ts index 2986caf94..16b692de1 100644 --- a/frontend/src/api/modules/system.ts +++ b/frontend/src/api/modules/system.ts @@ -34,6 +34,7 @@ export interface SystemUpdateDetail { notes: string | null cached: boolean updateSupported: boolean + restartConfirmationSupported?: boolean unsupportedReason: string | null warning: string | null } @@ -101,10 +102,22 @@ export function performSystemUpdate(data: SystemUpdateTarget, options: RequestOp }) } -export function restartSystem(options: RequestOptions = {}) { +export interface SystemRestartPlan { + targetVersion: string | null + releaseManifestSha256: string | null + configRevision: number + incompatiblePlugins: { instanceId: string, name: string, reason: string }[] +} + +export function checkSystemRestart() { + return request({ url: '/api/admin/system/restart/check', method: 'GET' }) +} + +export function restartSystem(confirmation?: SystemRestartPlan, options: RequestOptions = {}) { return request({ url: '/api/admin/system/restart', method: 'POST', + data: { confirmation }, ...options, }) } diff --git a/frontend/src/layout/components/SystemUpdateModal/index.vue b/frontend/src/layout/components/SystemUpdateModal/index.vue index e210fa707..cc8d6e113 100644 --- a/frontend/src/layout/components/SystemUpdateModal/index.vue +++ b/frontend/src/layout/components/SystemUpdateModal/index.vue @@ -1,5 +1,5 @@ + + diff --git a/frontend/src/views/plugins/components/PluginDetailModal.vue b/frontend/src/views/plugins/components/PluginDetailModal.vue index fd174a59c..3dc0bd160 100644 --- a/frontend/src/views/plugins/components/PluginDetailModal.vue +++ b/frontend/src/views/plugins/components/PluginDetailModal.vue @@ -9,6 +9,7 @@ import { RouterLink } from 'vue-router' import { configurationStatus, currentPluginInstance, hasPluginSettings, PLUGIN_STATUS_LABELS, pluginStatusType } from '../utils/catalog' import { artifactForInstance } from '../utils/model' import { pluginPageLocation } from '../utils/navigation' +import PluginCompatibilityWarning from './PluginCompatibilityWarning.vue' import PluginConfigurationSummary from './PluginConfigurationSummary.vue' import PluginHelpPopover from './PluginHelpPopover.vue' import PluginLegacyConfigurations from './PluginLegacyConfigurations.vue' @@ -88,7 +89,8 @@ watch(() => props.initialSection, value => section.value = value)
当前版本 {{ artifactForInstance(instance, plugin.artifacts)?.metadata.version ?? '版本不可用' }} - + + {{ PLUGIN_STATUS_LABELS[configurationStatus(instance)] }} @@ -113,13 +115,13 @@ watch(() => props.initialSection, value => section.value = value) - + - + diff --git a/frontend/src/views/plugins/components/PluginHelpPopover.vue b/frontend/src/views/plugins/components/PluginHelpPopover.vue index c77f2013f..aec99b44e 100644 --- a/frontend/src/views/plugins/components/PluginHelpPopover.vue +++ b/frontend/src/views/plugins/components/PluginHelpPopover.vue @@ -1,9 +1,9 @@