diff --git a/backend/crates/gateway-admin/src/model/plugins/instances.rs b/backend/crates/gateway-admin/src/model/plugins/instances.rs index 5dd803fae..bd497062b 100644 --- a/backend/crates/gateway-admin/src/model/plugins/instances.rs +++ b/backend/crates/gateway-admin/src/model/plugins/instances.rs @@ -152,6 +152,8 @@ pub struct PluginInstanceView { #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ConfigurePluginInstance { + /// 只随本次启动请求传递,不写入实例配置 + pub compatibility_confirmation: Option, /// 创建草稿固定一个 UUID,重试复用同一配置,不能作为编辑已有配置的入口 pub creation_id: Option, /// 编辑时检查用户看到的配置版本,避免覆盖另一窗口已经保存的内容 @@ -175,6 +177,16 @@ pub struct PluginInstanceReplacement { pub expected_revision: u64, } +/// 管理员确认的宿主、实例配置与目标包;不构成永久兼容豁免 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PluginCompatibilityConfirmation { + pub host_fingerprint: String, + pub instance_id: String, + pub instance_revision: u64, + pub artifact_sha256: String, +} + /// 恢复已安装旧版本的配置快照,并重新通过目标版本校验 #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -212,6 +224,9 @@ pub struct PluginVersionConfiguration { #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct PluginVersionPlan { + pub compatibility_warning: Option, + pub compatibility_confirmation: Option, + pub load_error: Option, pub instance_revision: u64, pub artifact_sha256: String, pub configuration: serde_json::Value, diff --git a/backend/crates/gateway-admin/src/model/plugins/mod.rs b/backend/crates/gateway-admin/src/model/plugins/mod.rs index 9d3a7eb04..b52b26642 100644 --- a/backend/crates/gateway-admin/src/model/plugins/mod.rs +++ b/backend/crates/gateway-admin/src/model/plugins/mod.rs @@ -62,6 +62,48 @@ impl PluginHostCompatibility { .iter() .any(|entry| entry.capability == capability && entry.versions.contains(&version)) } + + /// 启动和升级预检共用比较规则,只检查插件声明的能力 + #[must_use] + pub fn incompatibilities( + &self, + requirements: &PluginCompatibilityRequirements, + host: &semver::Version, + ) -> Vec { + let mut reasons = Vec::new(); + if !self + .manifest_schema_versions + .contains(&requirements.manifest_schema_version) + { + reasons.push(format!( + "清单 v{} 不在宿主支持范围内", + requirements.manifest_schema_version + )); + } + if !self + .protocol_versions + .contains(&requirements.protocol_version) + { + reasons.push(format!( + "RPC v{} 不在宿主支持范围内", + requirements.protocol_version + )); + } + if !semver::VersionReq::parse(&requirements.host_version) + .is_ok_and(|range| range.matches(host)) + { + reasons.push(format!( + "插件声明的宿主范围为 {},当前为 {host}", + requirements.host_version + )); + } + for (capability, version) in &requirements.capabilities { + if !self.supports_capability(capability, *version) { + reasons.push(format!("{capability} v{version} 不在宿主支持范围内")); + } + } + reasons + } } fn valid_versions(versions: &[u32]) -> bool { diff --git a/backend/crates/gateway-admin/src/ports/plugins.rs b/backend/crates/gateway-admin/src/ports/plugins.rs index c4db994c0..f531e3ec5 100644 --- a/backend/crates/gateway-admin/src/ports/plugins.rs +++ b/backend/crates/gateway-admin/src/ports/plugins.rs @@ -46,6 +46,15 @@ pub trait PluginPreparation: PluginRuntimeDiagnostics + PluginStateLifecycle { &self, snapshot: PluginInstanceSnapshot, ) -> Result; + + /// 确认由 Admin 校验配置版本,Runtime 复核宿主并仅放行目标实例 + async fn prepare_confirmed( + &self, + _snapshot: PluginInstanceSnapshot, + _confirmation: crate::model::plugins::instances::PluginCompatibilityConfirmation, + ) -> Result { + Err(AdminError::invalid("插件运行时不支持确认启动")) + } } /// 运行诊断独立于发布准备;没有进程事实时显式返回 None @@ -175,6 +184,10 @@ pub trait PluginDistribution: Send + Sync { /// Runtime 只解释插件包格式;安装事务与来源选择归 Admin #[async_trait] pub trait PluginPackageInspector: Send + Sync { + /// 当前宿主版本、兼容声明与平台的稳定身份,不含敏感信息 + fn compatibility_host_fingerprint(&self) -> Result { + Err(AdminError::internal("宿主兼容声明不可用")) + } /// 完整校验包并返回版本范围诊断;校验失败与可加载但未经宿主承诺的提醒分开 async fn compatibility_warning( &self, 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 ef77a8fc2..bcf2e9112 100644 --- a/backend/crates/gateway-admin/src/use_case/plugin_update.rs +++ b/backend/crates/gateway-admin/src/use_case/plugin_update.rs @@ -161,53 +161,24 @@ impl PluginSystemUpdatePreflight { .await, ) { (_, Err(error)) if error.kind() == AdminErrorKind::Invalid => { - Some("插件包或协议与目标版本不兼容".to_owned()) + Some(error.message().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 - } + let reasons = host.incompatibilities(&requirements, target); + (!reasons.is_empty()).then(|| reasons.join(",")) } } } else { match self .inspector - .inspect( - artifact.archive.clone(), - Some(instance.artifact_sha256.clone()), - ) + .compatibility_warning(artifact.archive, instance.artifact_sha256.clone()) .await { - Ok(_) => self - .inspector - .compatibility_warning( - artifact.archive, - instance.artifact_sha256.clone(), - ) - .await - .map_err(map_inspection_error)?, + Ok(warning) => warning, Err(error) if error.kind() == AdminErrorKind::Invalid => { - Some("插件与当前宿主不兼容".to_owned()) + Some(error.message().to_owned()) } Err(error) => return Err(map_inspection_error(error)), } 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 7168810f9..b861085d9 100644 --- a/backend/crates/gateway-admin/src/use_case/plugins/artifacts.rs +++ b/backend/crates/gateway-admin/src/use_case/plugins/artifacts.rs @@ -186,6 +186,8 @@ impl PluginsService { .configuration_ready(probe, &metadata) .await?; + let status = self.package_status(&metadata.sha256).await?; + let compatible = status.warning.is_none() && status.load_error.is_none(); // 其它管理员写入或同插件的并发安装可能推进全局 revision;稳定 creation ID // 配合每轮重新读取,既不会复制默认实例,也不会覆盖先完成的另一个版本 for _ in 0..3 { @@ -207,12 +209,13 @@ impl PluginsService { } let input = ConfigurePluginInstance { + compatibility_confirmation: None, replace_instances: Vec::new(), creation_id: Some(creation_id.clone()), expected_revision: None, name: metadata.display_name.clone(), artifact_sha256: metadata.sha256.clone(), - enabled: !configuration_required, + enabled: !configuration_required && compatible, configuration: configuration.clone(), secrets: None, bindings: bindings.clone(), diff --git a/backend/crates/gateway-admin/src/use_case/plugins/instances/migration.rs b/backend/crates/gateway-admin/src/use_case/plugins/instances/migration.rs index c4584e83a..72e087da3 100644 --- a/backend/crates/gateway-admin/src/use_case/plugins/instances/migration.rs +++ b/backend/crates/gateway-admin/src/use_case/plugins/instances/migration.rs @@ -9,8 +9,8 @@ use crate::{ AdminError, MutationContext, Revision, plugins::{ instances::{ - PluginInstance, PluginInstanceMutation, PluginInstanceReplacement, - PluginInstanceSnapshot, + PluginCompatibilityConfirmation, PluginInstance, PluginInstanceMutation, + PluginInstanceReplacement, PluginInstanceSnapshot, }, state::{PluginStateCommit, PluginStateConfiguration}, }, @@ -22,12 +22,18 @@ impl PluginsService { pub(super) async fn configure_instance_with_state_transition( &self, mut snapshot: PluginInstanceSnapshot, - previous: PluginInstance, mut target: PluginInstance, target_state: PluginStateConfiguration, replacements: &[PluginInstanceReplacement], + confirmation: Option<&PluginCompatibilityConfirmation>, context: &MutationContext, ) -> Result { + let previous = snapshot + .instances + .iter() + .find(|instance| instance.id == target.id) + .cloned() + .ok_or_else(|| AdminError::not_found("插件实例不存在"))?; let previous_state = self.preparation.validate(previous.clone()).await?; let original_revision = snapshot.config_revision; // 即使最终保持停用,迁移也必须由目标制品的受控候选执行 @@ -39,7 +45,9 @@ impl PluginsService { .retain(|instance| instance.id != migration_instance.id); snapshot.instances.push(migration_instance); apply_replacements(&mut snapshot, replacements); - let migration_prepared = self.preparation.prepare(snapshot).await?; + let migration_prepared = self + .prepare_instance_candidate(snapshot, confirmation) + .await?; let mut disabled_revision = None; let disabled = if previous.enabled { @@ -111,7 +119,9 @@ impl PluginsService { current.instances.push(target.clone()); current.config_revision = candidate_revision; apply_replacements(&mut current, replacements); - let prepared = self.preparation.prepare(current.clone()).await?; + let prepared = self + .prepare_instance_candidate(current.clone(), confirmation) + .await?; let result = self .store .save_instance_replacing( @@ -144,7 +154,7 @@ impl PluginsService { .await); } }; - self.publish_instance(current, prepared, &result, replacements) + self.publish_instance(current, prepared, &result, replacements, confirmation) .await?; drop(migration_prepared); Ok(result) @@ -197,7 +207,7 @@ impl PluginsService { ) .await .map_err(|error| map_store_error(error, "plugin"))?; - self.publish_instance(snapshot, prepared, &mutation, &[]) + self.publish_instance(snapshot, prepared, &mutation, &[], None) .await?; Ok::<(), AdminError>(()) } diff --git a/backend/crates/gateway-admin/src/use_case/plugins/instances/mutations.rs b/backend/crates/gateway-admin/src/use_case/plugins/instances/mutations.rs index 1a6a46b21..a2f5d8c90 100644 --- a/backend/crates/gateway-admin/src/use_case/plugins/instances/mutations.rs +++ b/backend/crates/gateway-admin/src/use_case/plugins/instances/mutations.rs @@ -98,15 +98,34 @@ impl PluginsService { if artifact.accepted_at.is_none() { return Err(AdminError::invalid("请先安装并确认完整信任该版本")); } - if input.enabled - && let Some(warning) = self - .package_status(&input.artifact_sha256) - .await? - .load_error + let confirmation = input.compatibility_confirmation.as_ref(); + if let Some(confirmed) = confirmation + && (!input.enabled + || existing.as_ref().is_none_or(|instance| { + instance.id != confirmed.instance_id + || instance.revision.get() != confirmed.instance_revision + }) + || input.artifact_sha256 != confirmed.artifact_sha256 + || self.inspector.compatibility_host_fingerprint()? != confirmed.host_fingerprint) { - return Err(AdminError::invalid(format!( - "{warning},无法启动,请安装兼容版本" - ))); + return Err(AdminError::conflict( + "宿主、插件包或配置已变化,请重新确认启动风险", + )); + } + if input.enabled { + let status = self.package_status(&input.artifact_sha256).await?; + if let Some(reason) = status.load_error { + return Err(AdminError::invalid(format!( + "{reason},无法启动,请安装兼容版本" + ))); + } + if let Some(reason) = status.warning + && confirmation.is_none() + { + return Err(AdminError::conflict(format!( + "插件版本不兼容:{reason};请先确认启动风险" + ))); + } } let same_plugin = |digest: &str| { artifacts.iter().any(|item| { @@ -223,10 +242,10 @@ impl PluginsService { return self .configure_instance_with_state_transition( snapshot, - existing.clone(), instance, state, &replacements, + confirmation, context, ) .await; @@ -235,7 +254,9 @@ impl PluginsService { snapshot.instances.push(instance.clone()); snapshot.config_revision = candidate_revision; apply_replacements(&mut snapshot, &replacements); - let prepared = self.preparation.prepare(snapshot.clone()).await?; + let prepared = self + .prepare_instance_candidate(snapshot.clone(), confirmation) + .await?; let result = self .store .save_instance_replacing( @@ -250,7 +271,7 @@ impl PluginsService { ) .await .map_err(|e| map_store_error(e, "plugin"))?; - self.publish_instance(snapshot, prepared, &result, &replacements) + self.publish_instance(snapshot, prepared, &result, &replacements, confirmation) .await?; Ok(result) } diff --git a/backend/crates/gateway-admin/src/use_case/plugins/instances/publication.rs b/backend/crates/gateway-admin/src/use_case/plugins/instances/publication.rs index bd1393616..fa2e2536a 100644 --- a/backend/crates/gateway-admin/src/use_case/plugins/instances/publication.rs +++ b/backend/crates/gateway-admin/src/use_case/plugins/instances/publication.rs @@ -7,13 +7,29 @@ use crate::{ model::{ AdminError, plugins::instances::{ - PluginInstanceMutation, PluginInstanceReplacement, PluginInstanceSnapshot, + PluginCompatibilityConfirmation, PluginInstanceMutation, PluginInstanceReplacement, + PluginInstanceSnapshot, }, }, use_case::publish_committed, }; impl PluginsService { + pub(super) async fn prepare_instance_candidate( + &self, + snapshot: PluginInstanceSnapshot, + confirmation: Option<&PluginCompatibilityConfirmation>, + ) -> Result { + match confirmation { + Some(confirmation) => { + self.preparation + .prepare_confirmed(snapshot, confirmation.clone()) + .await + } + None => self.preparation.prepare(snapshot).await, + } + } + /// 以事务返回的版本激活并发布,候选强引用必须覆盖整个提交后阶段 pub(super) async fn publish_instance( &self, @@ -21,6 +37,7 @@ impl PluginsService { prepared: ExtensionSetReference, mutation: &PluginInstanceMutation, replacements: &[PluginInstanceReplacement], + confirmation: Option<&PluginCompatibilityConfirmation>, ) -> Result<(), AdminError> { let prepared = if mutation.config_revision == snapshot.config_revision && mutation.instance.revision == snapshot.config_revision @@ -33,7 +50,8 @@ impl PluginsService { .retain(|item| item.id != mutation.instance.id); snapshot.instances.push(mutation.instance.clone()); apply_replacements(&mut snapshot, replacements); - self.preparation.prepare(snapshot).await? + self.prepare_instance_candidate(snapshot, confirmation) + .await? }; self.preparation .activate_state(&prepared, &mutation.instance) diff --git a/backend/crates/gateway-admin/src/use_case/plugins/versions.rs b/backend/crates/gateway-admin/src/use_case/plugins/versions.rs index 40f267c54..759d9b667 100644 --- a/backend/crates/gateway-admin/src/use_case/plugins/versions.rs +++ b/backend/crates/gateway-admin/src/use_case/plugins/versions.rs @@ -10,9 +10,9 @@ use crate::{ plugins::{ PluginArtifactMetadata, instances::{ - ConfigurePluginInstance, PluginInstance, PluginInstanceMutation, - PluginRollbackPlan, PluginRollbackTarget, PluginVersionConfiguration, - PluginVersionPlan, RollbackPluginInstance, + ConfigurePluginInstance, PluginCompatibilityConfirmation, PluginInstance, + PluginInstanceMutation, PluginRollbackPlan, PluginRollbackTarget, + PluginVersionConfiguration, PluginVersionPlan, RollbackPluginInstance, }, }, }, @@ -61,6 +61,7 @@ impl PluginsService { AdminError::invalid("该版本没有可恢复的配置,请通过版本切换检查当前设置") })?; let configuration = ConfigurePluginInstance { + compatibility_confirmation: None, creation_id: None, expected_revision: None, replace_instances: Vec::new(), @@ -142,7 +143,21 @@ impl PluginsService { .find(|instance| instance.id == id) .ok_or_else(|| AdminError::not_found("插件实例不存在"))?; let (configuration, restored) = self.version_configuration(instance, digest).await?; + let status = self.package_status(digest).await?; + let compatibility_confirmation = if status.warning.is_some() { + Some(PluginCompatibilityConfirmation { + host_fingerprint: self.inspector.compatibility_host_fingerprint()?, + instance_id: instance.id.clone(), + instance_revision: instance.revision.get(), + artifact_sha256: digest.to_owned(), + }) + } else { + None + }; Ok(PluginVersionPlan { + compatibility_warning: status.warning, + compatibility_confirmation, + load_error: status.load_error, instance_revision: instance.revision.get(), artifact_sha256: digest.to_owned(), configuration: configuration.configuration, @@ -179,6 +194,7 @@ impl PluginsService { .version_configuration(&existing, &input.artifact_sha256) .await?; let request = ConfigurePluginInstance { + compatibility_confirmation: None, creation_id: None, expected_revision: Some(existing.revision.get()), replace_instances: Vec::new(), 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 859cb75f1..095a087b4 100644 --- a/backend/crates/gateway-admin/tests/use_case/plugins/artifacts.rs +++ b/backend/crates/gateway-admin/tests/use_case/plugins/artifacts.rs @@ -39,6 +39,7 @@ struct FixtureData { artifact: InstalledPluginArtifact, snapshot: PluginInstanceSnapshot, saves: usize, + compatibility_warning: Option, publications: Vec, } @@ -61,6 +62,7 @@ impl Fixture { instances: existing.into_iter().collect(), }, saves: 0, + compatibility_warning: None, publications: Vec::new(), }), }) @@ -89,6 +91,14 @@ impl SnapshotControl for Fixture { #[async_trait] impl gateway_admin::ports::plugins::PluginPackageInspector for Fixture { + async fn compatibility_warning( + &self, + _: Arc<[u8]>, + _: String, + ) -> Result, AdminError> { + Ok(self.data.lock().unwrap().compatibility_warning.clone()) + } + async fn inspect( &self, archive: Arc<[u8]>, @@ -488,3 +498,20 @@ fn context() -> MutationContext { fn store_error(kind: AdminStoreErrorKind) -> AdminStoreError { AdminStoreError::new(kind, "plugin", "install fixture") } + +#[tokio::test] +async fn installing_an_incompatible_package_keeps_the_default_instance_stopped() { + let fixture = Fixture::new(metadata(true), None); + fixture.data.lock().unwrap().compatibility_warning = Some("宿主范围不匹配".into()); + let result = fixture + .service() + .accept_artifact(DIGEST, &context()) + .await + .unwrap(); + assert!(!result.configuration_required); + let data = fixture.data.lock().unwrap(); + assert_eq!(data.snapshot.instances.len(), 1); + assert!(!data.snapshot.instances[0].enabled); + assert!(!data.snapshot.instances[0].bindings.is_empty()); + assert!(data.artifact.accepted_at.is_some()); +} diff --git a/backend/crates/gateway-admin/tests/use_case/plugins/instances/mod.rs b/backend/crates/gateway-admin/tests/use_case/plugins/instances/mod.rs index 7ecebacdd..d6867af45 100644 --- a/backend/crates/gateway-admin/tests/use_case/plugins/instances/mod.rs +++ b/backend/crates/gateway-admin/tests/use_case/plugins/instances/mod.rs @@ -63,6 +63,10 @@ enum MigrationBehavior { #[async_trait] impl gateway_admin::ports::plugins::PluginPackageInspector for LifecycleFixture { + fn compatibility_host_fingerprint(&self) -> Result { + Ok("test-host".into()) + } + fn api_deprecations( &self, metadata: &PluginArtifactMetadata, @@ -282,6 +286,22 @@ impl ExtensionSetLease for Lease { #[async_trait] impl PluginPreparation for LifecycleFixture { + async fn prepare_confirmed( + &self, + snapshot: PluginInstanceSnapshot, + confirmation: gateway_admin::model::plugins::instances::PluginCompatibilityConfirmation, + ) -> Result { + assert_eq!(confirmation.host_fingerprint, "test-host"); + assert!( + snapshot + .instances + .iter() + .any(|instance| instance.id == confirmation.instance_id + && instance.artifact_sha256 == confirmation.artifact_sha256) + ); + self.prepare(snapshot).await + } + async fn configuration_ready( &self, instance: PluginInstance, @@ -731,6 +751,7 @@ fn rollback_input() -> RollbackPluginInstance { fn input() -> ConfigurePluginInstance { ConfigurePluginInstance { + compatibility_confirmation: None, replace_instances: Vec::new(), creation_id: None, expected_revision: None, @@ -1616,7 +1637,7 @@ async fn transient_compatibility_check_failure_can_be_retried() { } #[tokio::test] -async fn compatibility_warning_allows_enabling_and_persists_after_success() { +async fn incompatible_start_requires_confirmation_and_preserves_the_warning() { let fixture = LifecycleFixture::new(MigrationBehavior::Succeed); { let mut data = fixture.data.lock().unwrap(); @@ -1629,6 +1650,21 @@ async fn compatibility_warning_allows_enabling_and_persists_after_success() { assert!(view.load_error.is_none()); 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::Conflict); + assert!(fixture.data.lock().unwrap().saves.is_empty()); + assert!(fixture.data.lock().unwrap().prepared_snapshots.is_empty()); + let plan = service + .version_plan(&view.instance.id, OLD_ARTIFACT) + .await + .unwrap(); + let mut request = input(); + request.artifact_sha256 = OLD_ARTIFACT.into(); + request.compatibility_confirmation = plan.compatibility_confirmation; let result = service .configure_instance(Some(&view.instance.id), request, &context()) .await @@ -1639,3 +1675,54 @@ async fn compatibility_warning_allows_enabling_and_persists_after_success() { assert!(view.compatibility_warning.is_some()); assert!(view.load_error.is_none()); } + +#[tokio::test] +async fn incompatible_start_rejects_stale_confirmations_and_unloadable_packages() { + for mismatch in ["host", "instance", "revision", "artifact", "unloadable"] { + let fixture = LifecycleFixture::new(MigrationBehavior::Succeed); + { + let mut data = fixture.data.lock().unwrap(); + data.snapshot.instances[0].enabled = false; + data.compatibility_warning = Some("middleware v2 未声明兼容".into()); + if mismatch == "unloadable" { + data.inspection_error = Some(AdminErrorKind::Invalid); + } + } + let service = service(fixture.clone(), Arc::new(Published::default())); + let previous = fixture.data.lock().unwrap().snapshot.instances[0].clone(); + let mut confirmation = + gateway_admin::model::plugins::instances::PluginCompatibilityConfirmation { + host_fingerprint: "test-host".into(), + instance_id: previous.id.clone(), + instance_revision: previous.revision.get(), + artifact_sha256: OLD_ARTIFACT.into(), + }; + match mismatch { + "host" => confirmation.host_fingerprint = "another-host".into(), + "instance" => confirmation.instance_id = "another-instance".into(), + "revision" => confirmation.instance_revision += 1, + "artifact" => confirmation.artifact_sha256 = NEW_ARTIFACT.into(), + _ => {} + } + let mut request = input(); + request.artifact_sha256 = OLD_ARTIFACT.into(); + request.compatibility_confirmation = Some(confirmation); + let error = service + .configure_instance(Some(&previous.id), request, &context()) + .await + .err() + .unwrap(); + assert_eq!( + error.kind(), + if mismatch == "unloadable" { + AdminErrorKind::Invalid + } else { + AdminErrorKind::Conflict + } + ); + let data = fixture.data.lock().unwrap(); + assert!(data.saves.is_empty(), "{mismatch}"); + assert!(data.prepared_snapshots.is_empty(), "{mismatch}"); + assert!(!data.snapshot.instances[0].enabled); + } +} diff --git a/backend/crates/gateway-plugin/runtime/src/generation/prepare/instance.rs b/backend/crates/gateway-plugin/runtime/src/generation/prepare/instance.rs index 8667ceac9..622553621 100644 --- a/backend/crates/gateway-plugin/runtime/src/generation/prepare/instance.rs +++ b/backend/crates/gateway-plugin/runtime/src/generation/prepare/instance.rs @@ -18,6 +18,31 @@ use std::{sync::Arc, time::Duration}; const PROCESS_PREPARATION_TIMEOUT: Duration = Duration::from_secs(30); +#[derive(Debug)] +pub(super) enum InstancePreparationError { + UnloadablePackage(crate::PackageError), + IncompatiblePackage(String), + Failed(AdminError), +} + +impl InstancePreparationError { + pub(super) fn into_admin_error(self) -> AdminError { + match self { + Self::UnloadablePackage(error) => crate::package::inspection_error(error), + Self::IncompatiblePackage(reason) => { + AdminError::invalid(format!("插件版本不兼容:{reason}")) + } + Self::Failed(error) => error, + } + } +} + +impl From for InstancePreparationError { + fn from(error: AdminError) -> Self { + Self::Failed(error) + } +} + struct StartedInstance { instance: PluginInstance, package: Arc, @@ -31,16 +56,9 @@ impl PluginRuntime { instance: PluginInstance, target_revision: Revision, request_body_limit: Option, - ) -> Result { + allow_incompatible: bool, + ) -> Result { let authorization_binding = instance_fingerprint(&instance)?; - if self.restart_circuit_is_open(&RestartIdentity::new( - instance.id.clone(), - authorization_binding.clone(), - )) { - return Err(AdminError::unavailable( - "插件实例连续异常退出,已暂停自动重启;请检查配置后重新启用", - )); - } let validation_slot = self .validators .clone() @@ -65,46 +83,57 @@ impl PluginRuntime { Some(&instance.artifact_sha256), limits, ) - .map_err(|_| AdminError::invalid("插件恢复包校验失败"))?, + .map_err(InstancePreparationError::UnloadablePackage)?, ); - let (configuration, state_configuration) = - super::super::configuration::validate(&instance, package.manifest(), &schemas)?; - crate::adapter::observer::validate_bindings( - package.manifest(), - &instance.bindings, - )?; - crate::adapter::policy::validate_bindings(package.manifest(), &instance.bindings)?; - crate::adapter::middleware::validate_bindings( - package.manifest(), - &instance.bindings, - )?; - crate::adapter::upstream_adapter::validate_bindings( - package.manifest(), - &instance.bindings, - )?; - crate::adapter::frontend_authentication::validate_bindings( - package.manifest(), - &instance.bindings, - )?; + package + .manifest() + .package_for_platform(std::env::consts::OS, std::env::consts::ARCH) + .map_err(|error| InstancePreparationError::UnloadablePackage(error.into()))?; let requirements = crate::package::compatibility_requirements(package.manifest())?; - if let Some(warning) = + if let Some(reason) = crate::package::compatibility_warning(&requirements, &host_version)? { - tracing::warn!( - instance_id = instance.id, - warning, - "插件版本未经宿主保证,尝试启动" - ); + if !allow_incompatible { + return Err(InstancePreparationError::IncompatiblePackage(reason)); + } + tracing::warn!(instance_id = instance.id, reason, "手动尝试启动不兼容插件"); } - let package = Arc::new( - package - .prepare(&directory) - .map_err(|_| AdminError::unavailable("插件制品准备失败"))?, - ); - Ok((package, configuration, instance, state_configuration)) + let manifest = package.manifest(); + let (configuration, state_configuration) = + super::super::configuration::validate(&instance, manifest, &schemas)?; + crate::adapter::observer::validate_bindings(manifest, &instance.bindings)?; + crate::adapter::policy::validate_bindings(manifest, &instance.bindings)?; + crate::adapter::middleware::validate_bindings(manifest, &instance.bindings)?; + crate::adapter::upstream_adapter::validate_bindings(manifest, &instance.bindings)?; + crate::adapter::frontend_authentication::validate_bindings( + manifest, + &instance.bindings, + )?; + let package = + Arc::new(package.prepare(&directory).map_err(|error| match error { + crate::PackageError::Manifest(_) => { + InstancePreparationError::UnloadablePackage(error) + } + _ => AdminError::unavailable("插件制品准备失败").into(), + })?); + Ok::<_, InstancePreparationError>(( + package, + configuration, + instance, + state_configuration, + )) }) .await .map_err(|_| AdminError::internal("插件校验任务失败"))??; + if self.restart_circuit_is_open(&RestartIdentity::new( + instance.id.clone(), + authorization_binding.clone(), + )) { + return Err(AdminError::unavailable( + "插件实例连续异常退出,已暂停自动重启;请检查配置后重新启用", + ) + .into()); + } let manifest = package.package().manifest().clone(); let plugin_id = manifest .plugin_id() @@ -219,7 +248,8 @@ impl PluginRuntime { instance: PluginInstance, target_revision: Revision, request_body_limit: Option, - ) -> Result, AdminError> { + allow_incompatible: bool, + ) -> Result, InstancePreparationError> { // 启动控制期限只包住制品准备与握手,Ready 后每次注册使用协商预算 let StartedInstance { instance, @@ -228,7 +258,12 @@ impl PluginRuntime { mut prepared, } = tokio::time::timeout( PROCESS_PREPARATION_TIMEOUT, - self.start_instance(instance, target_revision, request_body_limit), + self.start_instance( + instance, + target_revision, + request_body_limit, + allow_incompatible, + ), ) .await .map_err(|_| AdminError::unavailable("插件启动超时"))??; @@ -256,7 +291,7 @@ impl PluginRuntime { let descriptor: Registration = serde_json::from_value(registration.result) .map_err(|_| AdminError::invalid("插件注册结果无效"))?; if descriptor.contributes != manifest.contributes || !registration.payload.is_empty() { - return Err(AdminError::invalid("插件注册结果与清单不符")); + return Err(AdminError::invalid("插件注册结果与清单不符").into()); } commands.extend( crate::adapter::command_line::prepare( diff --git a/backend/crates/gateway-plugin/runtime/src/generation/prepare/management.rs b/backend/crates/gateway-plugin/runtime/src/generation/prepare/management.rs index 1364e12a1..bdac01957 100644 --- a/backend/crates/gateway-plugin/runtime/src/generation/prepare/management.rs +++ b/backend/crates/gateway-plugin/runtime/src/generation/prepare/management.rs @@ -13,7 +13,7 @@ impl PluginRuntime { .load_instances() .await .map_err(|_| AdminError::unavailable("无法读取已安装插件"))?; - let reference = self.prepare_snapshot(snapshot, None, None).await?; + let reference = self.prepare_snapshot(snapshot, None, None, None).await?; let set = self.prepared_set(&reference).await?; Ok(crate::PluginCommandSession::new( reference, diff --git a/backend/crates/gateway-plugin/runtime/src/generation/prepare/snapshot.rs b/backend/crates/gateway-plugin/runtime/src/generation/prepare/snapshot.rs index d1fb32285..18b1fdb66 100644 --- a/backend/crates/gateway-plugin/runtime/src/generation/prepare/snapshot.rs +++ b/backend/crates/gateway-plugin/runtime/src/generation/prepare/snapshot.rs @@ -3,6 +3,7 @@ use super::{ PluginRuntime, diagnostics::{PreparationDiagnostic, runtime_failure_from_admin}, + instance::InstancePreparationError, set::{PreparedContributions, PreparedSet, fingerprint, instance_fingerprint}, }; use crate::ValidatedPackage; @@ -12,7 +13,10 @@ use gateway_admin::{ model::{ AdminError, Revision, plugins::{ - instances::{PluginInstance, PluginInstanceSnapshot}, + instances::{ + PluginCompatibilityConfirmation, PluginInstance, PluginInstanceRuntimeFailure, + PluginInstanceSnapshot, + }, state::PluginStateConfiguration, }, }, @@ -35,13 +39,14 @@ impl PluginRuntime { snapshot: PluginInstanceSnapshot, required_revision: Option, request_body_limit: Option, + confirmation: Option<&PluginCompatibilityConfirmation>, ) -> Result { let target_revision = snapshot.config_revision.get(); self.record_preparation(target_revision, PreparationDiagnostic::Preparing); let result = tokio::select! { biased; () = self.shutting_down.cancelled() => Err(AdminError::unavailable("插件运行时正在关闭")), - result = self.prepare_inner(snapshot, required_revision, request_body_limit) => result, + result = self.prepare_inner(snapshot, required_revision, request_body_limit, confirmation) => result, }; match &result { Ok(reference) => self.record_preparation( @@ -63,6 +68,7 @@ impl PluginRuntime { snapshot: PluginInstanceSnapshot, required_revision: Option, request_body_limit: Option, + confirmation: Option<&PluginCompatibilityConfirmation>, ) -> Result { if self.shutting_down.is_cancelled() { return Err(AdminError::unavailable("插件运行时正在关闭")); @@ -70,6 +76,19 @@ impl PluginRuntime { if snapshot.instances.len() > 64 { return Err(AdminError::invalid("最多配置 64 个插件实例")); } + if let Some(confirmation) = confirmation + && (confirmation.host_fingerprint + != crate::package::compatibility_host_fingerprint(&self.config.host_version)? + || !snapshot.instances.iter().any(|instance| { + instance.enabled + && instance.id == confirmation.instance_id + && instance.artifact_sha256 == confirmation.artifact_sha256 + })) + { + return Err(AdminError::conflict( + "宿主或插件包已变化,请重新确认启动风险", + )); + } let fingerprint = fingerprint(&snapshot)?; // 准备串行化与弱索引分锁;诊断和已发布请求不会等待外部 I/O let _prepare = self.prepare_lock.lock().await; @@ -83,6 +102,10 @@ impl PluginRuntime { .get(&fingerprint) .and_then(Weak::upgrade) .filter(|set| set.sessions_ready()) + .filter(|set| { + confirmation + .is_none_or(|confirmed| !set.failures.contains_key(&confirmed.instance_id)) + }) }; if let Some(set) = existing { if snapshot.instances.iter().any(|instance| { @@ -130,6 +153,7 @@ impl PluginRuntime { instance.clone(), snapshot.config_revision, request_body_limit, + confirmation.is_some_and(|confirmed| confirmed.instance_id == instance.id), ) .await }; @@ -147,8 +171,40 @@ impl PluginRuntime { contributions.append(candidate.contributions.clone()); sessions.push(candidate); } - Err(error) if required_revision == Some(instance.revision) => return Err(error), - Err(error) => { + Err(error) + if required_revision == Some(instance.revision) + || confirmation + .is_some_and(|confirmed| confirmed.instance_id == instance.id) => + { + return Err(error.into_admin_error()); + } + Err( + error @ (InstancePreparationError::UnloadablePackage(_) + | InstancePreparationError::IncompatiblePackage(_)), + ) => { + let code = if matches!(&error, InstancePreparationError::IncompatiblePackage(_)) + { + "version_incompatible" + } else { + "package_unloadable" + }; + let error = error.into_admin_error(); + tracing::error!( + event = "plugin.instance.quarantined", + instance_id = instance.id, + reason = error.message(), + "插件未通过启动兼容检查,未挂载任何能力" + ); + // 包无法装载时没有可执行能力,不能发布全局拒绝占位阻断宿主 + failures.insert( + instance.id.clone(), + PluginInstanceRuntimeFailure { + code: code.to_owned(), + message: error.message().to_owned(), + }, + ); + } + Err(InstancePreparationError::Failed(error)) => { // 目录没有拒绝请求用的 binding,不能在恢复失败时悄悄撤销仍启用的别名 // 不复制其他候选的注册结果;让发布事务保留调用方持有的旧有效快照 let has_retained_catalog = self @@ -360,7 +416,17 @@ impl PluginPreparation for PluginRuntime { snapshot: PluginInstanceSnapshot, ) -> Result { let required_revision = snapshot.config_revision; - self.prepare_snapshot(snapshot, Some(required_revision), None) + self.prepare_snapshot(snapshot, Some(required_revision), None, None) + .await + } + + async fn prepare_confirmed( + &self, + snapshot: PluginInstanceSnapshot, + confirmation: PluginCompatibilityConfirmation, + ) -> Result { + let required_revision = snapshot.config_revision; + self.prepare_snapshot(snapshot, Some(required_revision), None, Some(&confirmation)) .await } } @@ -397,7 +463,7 @@ impl PluginRuntime { if snapshot.config_revision.get() != revision.get() { return Err(ExtensionPreparationError); } - self.prepare_snapshot(snapshot, None, request_body_limit) + self.prepare_snapshot(snapshot, None, request_body_limit, None) .await .map_err(|_| ExtensionPreparationError) }) diff --git a/backend/crates/gateway-plugin/runtime/src/package/compatibility.rs b/backend/crates/gateway-plugin/runtime/src/package/compatibility.rs index 2438f2052..1f05ba19d 100644 --- a/backend/crates/gateway-plugin/runtime/src/package/compatibility.rs +++ b/backend/crates/gateway-plugin/runtime/src/package/compatibility.rs @@ -69,19 +69,16 @@ pub(crate) fn warning( requirements: &PluginCompatibilityRequirements, host: &semver::Version, ) -> Result, AdminError> { - let compatibility = host_compatibility()?; - let mut warnings = Vec::new(); - if !semver::VersionReq::parse(&requirements.host_version).is_ok_and(|range| range.matches(host)) - { - warnings.push(format!( - "插件声明的宿主范围为 {},当前为 {host}", - requirements.host_version - )); - } - for (capability, version) in &requirements.capabilities { - if !compatibility.supports_capability(capability, *version) { - warnings.push(format!("{capability} v{version} 不在宿主支持范围内")); - } - } + let warnings = host_compatibility()?.incompatibilities(requirements, host); Ok((!warnings.is_empty()).then(|| warnings.join(","))) } + +pub(crate) fn host_fingerprint(host: &semver::Version) -> Result { + use sha2::{Digest, Sha256}; + host_compatibility()?; + Ok(hex::encode(Sha256::digest(format!( + "{host}\n{}\n{}\n{HOST_COMPATIBILITY_JSON}", + std::env::consts::OS, + std::env::consts::ARCH, + )))) +} diff --git a/backend/crates/gateway-plugin/runtime/src/package/inspection.rs b/backend/crates/gateway-plugin/runtime/src/package/inspection.rs index 204227063..2fa17d411 100644 --- a/backend/crates/gateway-plugin/runtime/src/package/inspection.rs +++ b/backend/crates/gateway-plugin/runtime/src/package/inspection.rs @@ -120,6 +120,10 @@ impl PackageInspector { #[async_trait] impl PluginPackageInspector for PackageInspector { + fn compatibility_host_fingerprint(&self) -> Result { + super::compatibility::host_fingerprint(&self.host_version) + } + async fn compatibility_warning( &self, archive: Arc<[u8]>, @@ -200,6 +204,10 @@ impl PluginPackageInspector for PackageInspector { let _permit = permit; let package = ValidatedPackage::read(archive, Some(&expected_sha256), limits) .map_err(inspection_error)?; + package + .manifest() + .package_for_platform(std::env::consts::OS, std::env::consts::ARCH) + .map_err(|error| inspection_error(error.into()))?; super::compatibility::requirements(package.manifest()) }) .await @@ -207,12 +215,22 @@ impl PluginPackageInspector for PackageInspector { } } -fn inspection_error(error: PackageError) -> AdminError { +pub(crate) fn inspection_error(error: PackageError) -> AdminError { match error { PackageError::Manifest(gateway_plugin_sdk::ManifestError::Incompatible) => { AdminError::invalid("插件清单或协议版本不兼容,请使用当前 SDK 重新构建") } - _ => AdminError::invalid("插件包格式、兼容性或摘要校验失败"), + PackageError::Manifest(gateway_plugin_sdk::ManifestError::Platform) => { + AdminError::invalid("插件不支持当前运行平台") + } + PackageError::Manifest(gateway_plugin_sdk::ManifestError::Invalid) => { + AdminError::invalid("插件清单格式不合法") + } + PackageError::Digest => AdminError::invalid("插件包摘要校验失败,请重新安装"), + PackageError::Archive => AdminError::invalid("插件包损坏或归档格式不合法"), + PackageError::Path => AdminError::invalid("插件包包含不安全或重复路径"), + PackageError::Limit => AdminError::invalid("插件包超过大小或文件数量限制"), + PackageError::Cache => AdminError::unavailable("插件制品缓存暂不可用"), } } diff --git a/backend/crates/gateway-plugin/runtime/src/package/mod.rs b/backend/crates/gateway-plugin/runtime/src/package/mod.rs index 6506bf982..823c0ac92 100644 --- a/backend/crates/gateway-plugin/runtime/src/package/mod.rs +++ b/backend/crates/gateway-plugin/runtime/src/package/mod.rs @@ -8,7 +8,9 @@ mod validation; pub use cache::PreparedPackage; pub(crate) use compatibility::{ - requirements as compatibility_requirements, warning as compatibility_warning, + host_fingerprint as compatibility_host_fingerprint, requirements as compatibility_requirements, + warning as compatibility_warning, }; pub use inspection::PackageInspector; +pub(crate) use inspection::inspection_error; pub use validation::{PackageError, PackageLimits, ValidatedPackage}; diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/prepare/instance.rs b/backend/crates/gateway-plugin/runtime/tests/generation/prepare/instance.rs index 6cebbd789..30fdc60df 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/prepare/instance.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/prepare/instance.rs @@ -512,45 +512,304 @@ async fn abandoned_candidate_keeps_shared_process_failure_in_restart_budget() { } #[tokio::test] -async fn a_host_version_mismatch_still_restores_the_enabled_plugin() { - use std::sync::Arc; - let (cache, store, _) = super::super::setup().await; - let runtime = gateway_plugin_runtime::PluginRuntime::new( - store.clone(), +async fn incompatible_start_requires_scoped_confirmation_and_rechecks_after_restart() { + use gateway_admin::model::plugins::instances::{ + PluginCapabilityBinding, PluginCompatibilityConfirmation, PluginFailurePolicy, + }; + use gateway_admin::ports::plugins::{ + PluginPackageInspector as _, PluginRuntimeDiagnostics as _, + }; + use gateway_plugin_runtime::{PackageInspector, PackageLimits}; + for mismatch in ["host", "capability"] { + let mut contribution = + crate::support::contribution(Capability::Middleware, vec![Stage::Http], vec![], vec![]); + if mismatch == "capability" { + contribution.1.version = 3; + } + let (cache, store, initial) = + super::super::setup_with_contributions(Contributions::from([contribution])).await; + drop(initial); + let host = if mismatch == "host" { "3.0.0" } else { "1.0.0" }; + let runtime = compatibility_runtime(&cache, store.clone(), host); + let markers = tempfile::tempdir().unwrap(); + let marker = markers.path().join("started"); + let other_marker = markers.path().join("other-started"); + let mut snapshot = store.snapshot.lock().unwrap().clone(); + snapshot.instances[0].configuration = serde_json::json!({"startup_marker":marker}); + snapshot.instances[0].bindings = vec![PluginCapabilityBinding { + contribution: "test.example.middleware".into(), + stage: "http".into(), + order: 0, + failure_policy: PluginFailurePolicy::Reject, + event: None, + client_key_ids: vec![], + account_group_ids: vec![], + provider_ids: vec![], + models: vec![], + identity_bindings: vec![], + }]; + let mut other = snapshot.instances[0].clone(); + other.id = "other-instance".into(); + other.configuration = serde_json::json!({"startup_marker":other_marker}); + snapshot.instances.push(other); + *store.snapshot.lock().unwrap() = snapshot.clone(); + let restored = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap()) + .await + .unwrap(); + assert!(restored.can_serve()); + assert!(runtime.execution_registry().middleware(&restored).is_none()); + let diagnostics = runtime + .runtime_diagnostics(&snapshot, Some(1), Some(&restored)) + .await + .unwrap(); + assert_eq!( + diagnostics["instance-one"].failure.as_ref().unwrap().code, + "version_incompatible" + ); + assert_eq!(startup_count(&marker), 0); + assert_eq!(startup_count(&other_marker), 0); + assert!(snapshot.instances.iter().all(|instance| instance.enabled)); + + snapshot.config_revision = Revision::new(2).unwrap(); + snapshot.instances[0].revision = snapshot.config_revision; + assert!( + PluginPreparation::prepare(&runtime, snapshot.clone()) + .await + .is_err() + ); + let inspector = PackageInspector::new(PackageLimits::default(), host.parse().unwrap()); + let mut confirmation = PluginCompatibilityConfirmation { + host_fingerprint: "stale-host".into(), + instance_id: "instance-one".into(), + instance_revision: 1, + artifact_sha256: snapshot.instances[0].artifact_sha256.clone(), + }; + assert!( + PluginPreparation::prepare_confirmed(&runtime, snapshot.clone(), confirmation.clone()) + .await + .is_err() + ); + assert_eq!(startup_count(&marker), 0); + confirmation.host_fingerprint = inspector.compatibility_host_fingerprint().unwrap(); + let confirmed = + PluginPreparation::prepare_confirmed(&runtime, snapshot.clone(), confirmation) + .await + .unwrap(); + assert!( + runtime + .execution_registry() + .middleware(&confirmed) + .is_some() + ); + let diagnostics = runtime + .runtime_diagnostics(&snapshot, Some(2), Some(&confirmed)) + .await + .unwrap(); + assert_eq!( + diagnostics["instance-one"].status, + PluginInstanceRuntimeStatus::Running + ); + assert_eq!( + diagnostics["other-instance"].failure.as_ref().unwrap().code, + "version_incompatible" + ); + assert_eq!(startup_count(&marker), 1); + assert_eq!(startup_count(&other_marker), 0); + *store.snapshot.lock().unwrap() = snapshot.clone(); + let reused = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(2).unwrap()) + .await + .unwrap(); + assert_eq!(reused.id(), confirmed.id()); + drop(reused); + drop(confirmed); + drop(restored); + runtime.shutdown().await; + super::super::wait_until_empty(cache.path()).await; + + let restarted = compatibility_runtime(&cache, store.clone(), host); + let restored = + ExtensionPreparationPort::prepare(&restarted, ConfigRevision::new(2).unwrap()) + .await + .unwrap(); + assert!( + restarted + .execution_registry() + .middleware(&restored) + .is_none() + ); + assert_eq!( + startup_count(&marker), + 1, + "confirmation must not survive host restart" + ); + assert_eq!(startup_count(&other_marker), 0); + drop(restored); + restarted.shutdown().await; + super::super::wait_until_empty(cache.path()).await; + } +} + +fn compatibility_runtime( + cache: &tempfile::TempDir, + store: std::sync::Arc, + host: &str, +) -> gateway_plugin_runtime::PluginRuntime { + gateway_plugin_runtime::PluginRuntime::new( store.clone(), + store, gateway_plugin_runtime::PluginRuntimeConfig { cache_directory: cache.path().to_owned(), - host_version: "3.0.0".parse().unwrap(), + host_version: host.parse().unwrap(), package_limits: Default::default(), rpc_limits: Default::default(), restart_circuit: Default::default(), }, - Arc::new(gateway_host::outbound::HttpClient::new().unwrap()), - Arc::new(gateway_host::process::ProcessSupervisor::default()), - ); - let restored = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap()) - .await - .unwrap(); - assert!(restored.can_serve()); - let snapshot = store.snapshot.lock().unwrap().clone(); - let diagnostics = gateway_admin::ports::plugins::PluginRuntimeDiagnostics::runtime_diagnostics( - &runtime, - &snapshot, - Some(1), - Some(&restored), + std::sync::Arc::new(gateway_host::outbound::HttpClient::new().unwrap()), + std::sync::Arc::new(gateway_host::process::ProcessSupervisor::default()), ) - .await - .unwrap(); - assert!(restored.is_ready()); - assert!(snapshot.instances[0].enabled); - assert_eq!( - diagnostics["instance-one"].status, - PluginInstanceRuntimeStatus::Running - ); - assert!(diagnostics["instance-one"].failure.is_none()); - drop(restored); - runtime.shutdown().await; - super::super::wait_until_empty(cache.path()).await; +} + +#[tokio::test] +async fn unloadable_packages_have_no_guards_and_cannot_be_forced_to_start() { + use gateway_admin::model::plugins::instances::{ + PluginCapabilityBinding, PluginCompatibilityConfirmation, PluginFailurePolicy, + }; + use gateway_admin::ports::plugins::{ + PluginPackageInspector as _, PluginRuntimeDiagnostics as _, + }; + use gateway_plugin_runtime::{PackageInspector, PackageLimits, ValidatedPackage}; + use sha2::{Digest as _, Sha256}; + use std::{collections::BTreeMap, sync::Arc}; + for defect in ["rpc", "manifest", "platform", "archive", "digest"] { + let (cache, store, initial) = super::super::setup().await; + drop(initial); + let mut store = Arc::try_unwrap(store).ok().unwrap(); + let mut broken = store.artifacts.values().next().unwrap().clone(); + let package = + ValidatedPackage::read(broken.archive.clone(), None, PackageLimits::default()).unwrap(); + let mut manifest = package.manifest().clone(); + let bytes = b"must never execute"; + manifest + .package + .as_mut() + .unwrap() + .files + .insert("bin/worker".into(), hex::encode(Sha256::digest(bytes))); + match defect { + "rpc" => manifest.package.as_mut().unwrap().protocol_version += 1, + "manifest" => manifest.manifest_version += 1, + "platform" => { + manifest.package.as_mut().unwrap().target.os = if std::env::consts::OS == "windows" + { + "linux" + } else { + "windows" + } + .into() + } + _ => {} + } + broken.archive = if defect == "archive" { + Arc::from(&b"broken archive"[..]) + } else { + crate::support::archive(BTreeMap::from([ + ("plugin.json".into(), serde_json::to_vec(&manifest).unwrap()), + ("bin/worker".into(), bytes.to_vec()), + ])) + }; + broken.metadata.sha256 = if defect == "digest" { + "0".repeat(64) + } else { + hex::encode(Sha256::digest(&broken.archive)) + }; + let mut failed = store.snapshot.lock().unwrap().instances[0].clone(); + failed.id = "unloadable".into(); + failed.artifact_sha256 = broken.metadata.sha256.clone(); + failed.bindings = [ + "http", + "request", + "routing", + "scheduling", + "retry", + "upstream", + "authentication", + ] + .into_iter() + .map(|stage| PluginCapabilityBinding { + contribution: "test.example.saved".into(), + stage: stage.into(), + order: 0, + failure_policy: PluginFailurePolicy::Reject, + event: None, + client_key_ids: vec![], + account_group_ids: vec![], + provider_ids: vec![], + models: vec![], + identity_bindings: vec![], + }) + .collect(); + store + .artifacts + .insert(broken.metadata.sha256.clone(), broken); + store.snapshot.lock().unwrap().instances.push(failed); + let store = Arc::new(store); + let runtime = compatibility_runtime(&cache, store.clone(), "1.0.0"); + let restored = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap()) + .await + .unwrap(); + assert!( + runtime.execution_registry().middleware(&restored).is_none(), + "{defect}" + ); + assert!( + runtime.policy_registry().resolve(&restored).is_none(), + "{defect}" + ); + assert!( + runtime + .frontend_authentication_registry() + .resolve(&restored) + .is_none(), + "{defect}" + ); + let mut snapshot = store.snapshot.lock().unwrap().clone(); + let diagnostics = runtime + .runtime_diagnostics(&snapshot, Some(1), Some(&restored)) + .await + .unwrap(); + assert_eq!( + diagnostics["unloadable"].failure.as_ref().unwrap().code, + "package_unloadable", + "{defect}" + ); + assert_eq!( + diagnostics["instance-one"].status, + PluginInstanceRuntimeStatus::Running + ); + snapshot.config_revision = Revision::new(2).unwrap(); + snapshot.instances[1].revision = snapshot.config_revision; + let confirmation = PluginCompatibilityConfirmation { + host_fingerprint: PackageInspector::new( + PackageLimits::default(), + "1.0.0".parse().unwrap(), + ) + .compatibility_host_fingerprint() + .unwrap(), + instance_id: "unloadable".into(), + instance_revision: 1, + artifact_sha256: snapshot.instances[1].artifact_sha256.clone(), + }; + assert!( + PluginPreparation::prepare_confirmed(&runtime, snapshot, confirmation) + .await + .is_err(), + "{defect}" + ); + assert!(restored.is_ready()); + drop(restored); + runtime.shutdown().await; + super::super::wait_until_empty(cache.path()).await; + } } #[tokio::test] diff --git a/backend/crates/gateway-plugin/runtime/tests/package/compatibility.rs b/backend/crates/gateway-plugin/runtime/tests/package/compatibility.rs index 6277545b1..cb409f736 100644 --- a/backend/crates/gateway-plugin/runtime/tests/package/compatibility.rs +++ b/backend/crates/gateway-plugin/runtime/tests/package/compatibility.rs @@ -147,3 +147,46 @@ async fn compatibility_warning_rejects_invalid_packages_and_digest_mismatches() .unwrap_err(); assert_eq!(error.kind(), gateway_admin::model::AdminErrorKind::Invalid); } + +#[tokio::test] +async fn runtime_and_update_preflight_compare_only_declared_contracts() { + let host: PluginHostCompatibility = + serde_json::from_str(include_str!("../../plugin-host-compatibility.json")).unwrap(); + for capability_version in [6, 3] { + let mut contribution = + crate::support::contribution(Capability::Middleware, vec![Stage::Http], vec![], vec![]); + contribution.1.version = capability_version; + let archive = crate::support::package_with_contributions( + b"not-an-executable", + Contributions::from([contribution]), + ); + let digest = hex::encode(Sha256::digest(&archive)); + for version in ["1.0.0", "3.0.0"] { + let version = semver::Version::parse(version).unwrap(); + let inspector = PackageInspector::new(PackageLimits::default(), version.clone()); + let requirements = inspector + .compatibility(archive.clone(), digest.clone()) + .await + .unwrap(); + let reasons = host.incompatibilities(&requirements, &version); + assert_eq!( + inspector + .compatibility_warning(archive.clone(), digest.clone()) + .await + .unwrap(), + (!reasons.is_empty()).then(|| reasons.join(",")) + ); + assert_eq!( + reasons.len(), + usize::from(capability_version != 6) + usize::from(version.major != 1) + ); + let mut unsupported = requirements; + unsupported.manifest_schema_version = 999; + unsupported.protocol_version = 999; + assert_eq!( + host.incompatibilities(&unsupported, &version).len(), + reasons.len() + 2 + ); + } + } +} diff --git a/backend/crates/gateway-plugin/sdk/docs/manifest.md b/backend/crates/gateway-plugin/sdk/docs/manifest.md index 3c8f95b1a..4b96cfa18 100644 --- a/backend/crates/gateway-plugin/sdk/docs/manifest.md +++ b/backend/crates/gateway-plugin/sdk/docs/manifest.md @@ -38,7 +38,7 @@ RPC v7 的帧布局、编码与会话生命周期见[通信边界](../README.md# 安装包清单校验与已安装元数据读取是两个边界:清单拒绝未知字段,并检查必需字段、类型和版本; 数据库元数据的读取规则见[制品与发布](../../../../../docs/architecture.md#制品配置与发布),不改变清单或 RPC 合同。 -`engines.codex-proxy-rs` 声明插件所需的宿主范围。范围或能力版本不匹配时持续提示风险,允许尝试启动;包结构、平台与 RPC 封装校验失败阻止加载。CLI 严格校验作者声明是否符合 SDK 支持的合同 +`engines.codex-proxy-rs` 声明插件所需的宿主范围。范围或已声明能力版本不匹配时不自动启动,管理员确认风险后可尝试本次启动;确认不跨宿主重启保留。包结构、清单格式、平台与 RPC 封装校验失败阻止加载,不能通过风险确认绕过。CLI 严格校验作者声明是否符合 SDK 支持的合同 ## 接口弃用 diff --git a/deploy/README.md b/deploy/README.md index a393260b3..ee4650f0e 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -716,11 +716,14 @@ Release 必须提供当前 OS/架构的 `codex-proxy-rs___.ta | `plugin-release-manifest.json` | 固定宿主版本、提交、平台与支持的插件合同;插件列表当前为空 | | 可执行文件同目录的 `plugins/official` | 随受信宿主发行物部署的只读目录,与二进制、Web 资源成套更新或恢复 | -- 下载更新时校验发行身份,插件合同不兼容不阻止安装。点击重启时检查已安装目标版本,列出不兼容插件的名称和原因;确认后保留启用配置并重启,逐个尝试启动插件,版本警告持续显示 -- 回滚同样在重启前提示插件兼容风险,不因版本不匹配批量停用插件。更新或回滚期间的并发配置变更会阻止切换,文件替换失败或取消时恢复整组文件 +- 下载更新时校验发行身份,插件合同不兼容不阻止安装。点击重启时检查已安装目标版本,列出不兼容插件的名称和原因;确认后重启,不兼容实例保持未启动,启用配置、绑定、密钥和私有数据保留 +- 回滚同样在重启前提示插件兼容风险,启动时重新检查,不改写持久启用配置。更新或回滚期间的并发配置变更会阻止切换,文件替换失败或取消时恢复整组文件 - 非空官方清单中的包经身份、平台和摘要校验后幂等导入;导入不确认信任、不创建配置、不启用,也不删除旧包。管理员确认信任后才执行默认配置流程;重复摘要保留首次安装出处 - `sealed` 是构建封口标记,不是密码学签名。普通上传、URL 或 GitHub 安装不能获得 `builtin` 身份,独立教学示例也不例外 +宿主启动检查覆盖管理页更新、Docker 与手工替换二进制。不兼容实例不挂载任何能力或故障占位,其额外认证和拦截停止生效,宿主原生认证继续执行。 +宿主范围或能力版本不匹配时,可在插件页确认风险后尝试本次启动;清单、RPC、平台或包完整性错误不能绕过。具体操作见[插件故障处理](../docs/plugins.md#故障处理) + 部署须保留完整的二进制、Web 资源与官方插件目录;不要手动修改制品元数据或迁移 checksum 绕过校验。 macOS arm64 提供构建产物;部署前需验证目标平台的插件运行与上游调用,不能只以打包成功判断可用 diff --git a/docs/api.md b/docs/api.md index 1771a9cbc..72f3e9135 100644 --- a/docs/api.md +++ b/docs/api.md @@ -2291,7 +2291,7 @@ Provider metadata 分别保留 `requestedServiceTier` 与 `upstreamServiceTier` 客户端重启前调用 `restart/check`,返回 `targetVersion`、`releaseManifestSha256`、`configRevision` 和 `incompatiblePlugins`, 每项包含 `instanceId`、`name`、`reason`,检查不修改插件状态。源码运行的目标版本与发行摘要为空,检查当前宿主兼容性。 存在不兼容插件时,客户端展示列表并取得确认后,将完整检查结果作为 `confirmation` 提交重启请求。 -服务端在重启锁内重新检查,目标或配置变化返回 `40901`,须重新检查并确认。确认后保留启用配置,重启时逐个尝试加载插件。 +服务端在重启锁内重新检查,目标或配置变化返回 `40901`,须重新检查并确认。确认后保留启用配置,重启时不兼容实例保持未启动,不挂载能力或故障占位;插件额外认证和拦截停止生效,宿主原生认证继续执行。 没有兼容性风险时可省略确认,不切换插件版本、接受其他制品或改写配置版本 重启受理后,旧服务拒绝更新、回滚和重复重启,返回 `40901`;客户端等待新进程就绪后重新连接 @@ -2411,7 +2411,7 @@ URL 与 GitHub 的 `location.sha256` 在解析时均可省略,由服务端计 接受制品时,若该插件没有任何实例,服务端使用稳定的 creation ID 创建一份默认实例:普通配置取 schema 默认值, 敏感字段不复制默认值;按下文[能力绑定规则](#能力绑定与范围)为适用贡献项及阶段建立空范围默认 binding。 -仅缺少必填项时 `configurationRequired=true` 且实例保持停用;配置完整时实例直接启用。 +缺少必填项时 `configurationRequired=true` 且实例保持停用;配置完整且版本兼容时直接启用,宿主范围或能力版本不匹配时保留停用,等待手动确认。 非法 schema、类型错误或把敏感字段放入普通配置会拒绝安装。已有该插件实例时不额外创建或切换实例, `defaultInstanceId=null`。相同摘要的接受和默认实例创建可重试,不产生副本 @@ -2488,8 +2488,15 @@ GitHub 的 `location` 使用 `kind: "github"`、`repository: "owner/repo"`、`ta 列表项包含 `id`、`name`、`artifactSha256`、`enabled`、`configurationRequired`、`configuration`、 `secretFields`、`bindings`、`revision`、`running`、`publishedRevision`、`runtime`、`compatibilityWarning`、`loadError` 和 `apiDeprecations`。 -`compatibilityWarning` 表示宿主范围或能力版本不匹配,允许手动和自动启动,成功运行后仍保留提醒。 -`loadError` 表示包结构、平台等加载检查失败,非空时拒绝启用;两项无对应问题时为 `null`,停用实例也返回检查结果。运行故障通过 `runtime.failure` 返回,不改写 `enabled`。 +`compatibilityWarning` 表示宿主范围或已声明能力版本不匹配,不自动启动;手动确认后可尝试本次启动,成功运行后仍保留提醒。 +`loadError` 表示包结构、清单、RPC 或平台等加载检查失败,非空时拒绝启用且不能通过确认覆盖;两项无对应问题时为 `null`,停用实例也返回检查结果。运行故障通过 `runtime.failure` 返回,不改写 `enabled`。 +未通过启动准入的实例没有执行贡献或故障占位。恢复诊断使用 `runtime.failure.code=version_incompatible`(可确认尝试)或 `package_unloadable`(不能加载),`running=false`,不改写 `enabled` + +手动启动软不兼容实例时,先读取该实例及目标包的 `version-plan`,向管理员展示 `compatibilityWarning`。 +确认后通过 `instances/update` 提交 `enabled=true` 和原样返回的 `compatibilityConfirmation`,其中包含 `hostFingerprint`、`instanceId`、`instanceRevision` 和 `artifactSha256`。 +服务端复核宿主、实例配置版本和目标包,缺少确认或确认过期返回 409;包无法加载返回 400。确认仅放行本次目标实例,不持久化,不放行其他实例,下次宿主启动重新检查。 +创建软不兼容实例时先保存为停用,再读取计划确认启动;停用请求不接受确认信息 + `configurationRequired` 由当前制品 schema 与已保存的普通/敏感配置实时派生,不写入数据库;它只表示仍缺少必填值。停用实例也不能保存 类型错误、非法 schema 或把敏感字段混入普通配置 @@ -2501,11 +2508,11 @@ GitHub 的 `location` 使用 `kind: "github"`、`repository: "owner/repo"`、`ta 私有状态通过 [SDK 回调](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#状态日志与迁移)访问,不提供任意读写的管理 HTTP 接口。 升级若需要迁移,实例可能暂时停用;迁移失败且发生并发配置变更时保持停用,不覆盖管理员的新配置。 版本切换只接受同一插件已安装且已接受的制品,`expectedRevision` 必须等于实例列表中的 `revision`,过期请求返回 409。 -`version-plan` 返回 `instanceRevision`、`artifactSha256`、`configuration`、`secretFields`、`bindings` 和 `restored`, -最后一项表示使用了目标版本的配置快照。没有快照时只补充缺失的 schema 默认值,保留当前显式值;功能范围按能力与阶段映射到目标声明, +`version-plan` 返回 `instanceRevision`、`artifactSha256`、`configuration`、`secretFields`、`bindings`、`restored`、`compatibilityWarning`、`compatibilityConfirmation` 和 `loadError`。 +`restored` 表示使用了目标版本的配置快照,兼容字段适用于当前包和版本切换目标;无软不兼容时确认信息为 `null`。没有快照时只补充缺失的 schema 默认值,保留当前显式值;功能范围按能力与阶段映射到目标声明, 新能力使用默认绑定,已关闭的既有能力继续关闭,客户端认证仍须显式身份映射。草稿不代表已通过校验 -`switch-version` 重新生成草稿并执行准备与提交,名称和启停状态保持不变。不兼容设置返回 400,当前设置不变; +`switch-version` 重新生成草稿并执行准备与提交,名称和启停状态保持不变。不兼容设置返回 400,启用软不兼容目标但缺少风险确认返回 409,当前设置不变; 可读取草稿并通过 `instances/update` 携带原 `expectedRevision` 提交修正。不会推断字段改名或丢弃未知参数与密钥 每个实例、制品摘要保存一份最近启用时提交的配置快照,包含普通配置、secret 与 binding,停用草稿不覆盖快照。 diff --git a/docs/architecture.md b/docs/architecture.md index a41b3445a..990d8e259 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -143,10 +143,10 @@ flowchart LR - **安装意味着完整信任**:只读校验不持久化;上传、URL 和 GitHub 的确认安装记录对精确摘要的信任, 官方发行导入仍须管理员确认。若插件尚无实例,Admin 以稳定 ID 合并 schema 默认值与适用贡献项 binding: - 配置完整则启用,缺少必填项则停用待补;已有实例时不复制配置或切换版本 + 配置完整且兼容则启用,缺少必填项或版本不匹配则保持停用;已有实例时不复制配置或切换版本 - **持久化投影与包校验分离**:Admin 定义已校验制品的读取投影,Store 忽略未知字段,可选字段缺失时使用清单默认值;身份、摘要和执行所需事实必填,已知字段类型错误须报错。 - 读取和重复安装不重写 JSON,结构与语义变更由数据库迁移管理。包清单和 RPC 封装按 SDK 校验;能力版本及宿主范围用于兼容性提示,实例能否运行由实际调用结果确定 -- **兼容声明由发行物持有**:Runtime 的 `package/compatibility` 检查宿主范围与能力版本,`compatibility` 根据弃用清单生成管理端提示与加载日志。 + 读取和重复安装不重写 JSON,结构与语义变更由数据库迁移管理。包清单和 RPC 封装按 SDK 校验;宿主范围与已声明能力版本参与启动准入,运行就绪仍由实际准备结果确定 +- **兼容声明由发行物持有**:Runtime 的 `package/compatibility` 读取宿主声明,Admin 的宿主兼容模型集中比较清单、RPC、宿主范围与已声明能力版本,供启动和升级预检共用;`compatibility` 根据弃用清单生成管理端提示与加载日志。 Admin 透传这些提示,不持久化倒计时或根据实际字段使用猜测兼容性;`release/publish` 维护[兼容窗口](../backend/crates/gateway-plugin/sdk/docs/manifest.md#接口弃用) - **一份发布快照**:数据面和管理页面使用同一不可变扩展集合;CLI 复用准备与调用合同,按次读取持久化配置。 新配置准备成功后才提交发布,在途请求持有旧集合直到结束;管理目标和回调另按当前身份与版本复验 @@ -157,7 +157,10 @@ flowchart LR 诊断可以明确返回不可用;状态激活、实例排空与迁移必须由实现显式处理,不以缺省空操作或静默成功代替 - **控制面查询不持有变更资源**:实例查询、静态配置校验、变更与迁移编排分别归属 `instances` 的子模块;提交后发布与迁移恢复共用 `publication` 收尾。 包检查缓存只保存按不可变制品摘要产生的静态结果,加载与检查不持有缓存锁,暂时性错误不进入缓存 -- **插件故障按实例隔离**:恢复时将启动失败的实例及其错误保留在发布集合中,正常实例和原生能力继续发布; +- **启动准入不发布故障占位**:恢复时,不兼容或不可加载实例只保留诊断,不启动进程、不注册贡献或拒绝/委托占位,也不改写启用配置。 + Admin 复核手动确认对应的实例、包摘要与配置版本,Runtime 复核宿主版本、兼容声明和平台身份,只为本次目标实例放宽宿主范围或能力版本检查。 + 确认沿本次准备、迁移和发布传递,不持久化;已就绪会话按既有规则共享,新宿主进程重新检查。硬性包校验不能覆盖,未挂载插件的额外认证和拦截停止生效,宿主原生认证继续执行 +- **插件故障按实例隔离**:通过准入后的恢复失败将实例及其错误保留在发布集合中,正常实例和原生能力继续发布; 用户正在修改的实例仍须准备成功才能提交。集合区分进程就绪、可继续服务与需要后台重建,单个进程退出不撤销全部请求快照。 能力响应无法解码或消费时只结束所属调用,不连带停止共享进程;帧封装、调用关联或传输损坏才终止 RPC 会话。 故障绑定保留原有作用范围和拒绝/委托策略,入口认证故障不降级为其他认证方式;宿主配置存储与 revision 无法确认时仍停止新请求 diff --git a/docs/plugins.md b/docs/plugins.md index 4a31b8dfc..b6d6eb291 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -10,8 +10,8 @@ flowchart LR A[选择来源] --> B[校验包并确认来源] B --> C[确认安装并接受制品] C --> D[生成默认配置] - D -->|配置完整| E[启用并发布] - D -->|缺少必填项| F[补充配置后启用] + D -->|配置完整且兼容| E[启用并发布] + D -->|缺少必填项或版本不匹配| F[补充配置或确认风险后启用] ``` 安装包须通过结构、目标平台和进程协议校验;宿主版本范围或能力版本不匹配时提示兼容风险,处理方式见[故障处理](#故障处理) @@ -34,7 +34,7 @@ flowchart LR 1. 从 schema 填入普通配置默认值,不把敏感字段的默认值写入普通配置 2. 为需要实例范围的贡献项生成默认绑定;模型目录、管理页面、命令行和自动维护按声明注册,入口认证不自动绑定 -3. 配置完整时直接启用并发布;仅缺少必填项时保存为停用并打开配置页 +3. 配置完整且版本兼容时直接启用并发布;缺少必填项或版本不匹配时保存为停用,补充配置或确认风险后再启用 类型错误、非法 schema 或把敏感值混入普通配置会直接拒绝,不会伪装成“待配置”。更新已有插件时不会创建另一套配置, 管理端在包安装完成后提示确认切换,检查通过后应用目标版本,不兼容时保留原版本并打开待调整的设置 @@ -115,14 +115,16 @@ flowchart LR ### 故障处理 -宿主版本范围或能力版本不匹配时,管理页持续显示黄色兼容性提醒,允许手动启动和随宿主自动启动。 -启动成功不代表语义兼容,警告不会自动消失。应选择符合宿主合同的插件包,版本要求见 [SDK](../backend/crates/gateway-plugin/sdk/README.md);宿主更新和回滚的重启检查见[部署说明](../deploy/README.md#插件兼容与发行目录) +宿主启动时检查插件清单、RPC、平台、宿主范围和已声明能力版本。不兼容实例保持未启动,不挂载能力或故障占位,启用配置、绑定、密钥与私有数据保留;这同样适用于 Docker 与手工升级 -包无法解析、平台不符或 RPC 握手失败时,只有对应插件无法启动。运行中进程崩溃、帧边界、调用关联或传输错误会停止该实例, -显示红色感叹号及脱敏错误详情,保留启用配置,可在详情中重新启动或切换版本。连续故障受重启次数限制 +宿主范围或能力版本不匹配时,页面以黄色三角感叹号标记,悬浮或点击显示“未启动:版本不兼容”及原因。在插件详情点击启动,确认“启动不兼容版本可能导致系统不稳定”后可尝试;确认只适用于当前宿主、插件包和配置版本的本次启动,下次宿主启动重新检查。配置或目标变化后需要重新确认,成功启动后仍保留兼容性提醒 -能力响应无法解码只使当前调用失败;上游 429、用户请求错误和插件主动拒绝请求等业务结果不会直接停止插件。其他插件、原生转发和管理端继续运行, -不因单个插件故障撤销整个宿主的运行配置。命中故障插件绑定的请求仍遵循既有拒绝或委托策略,入口认证插件失败不会绕过认证 +包损坏、清单或 RPC 格式不兼容、平台不符时显示具体原因,不能强行启动,需更换兼容包。未启动插件提供的额外认证、拦截与其他能力不生效,宿主原生认证继续执行。版本要求见 [SDK](../backend/crates/gateway-plugin/sdk/README.md),重启检查见[部署说明](../deploy/README.md#插件兼容与发行目录) + +通过兼容检查后,RPC 握手失败或运行中进程崩溃、帧边界、调用关联或传输错误会使对应实例未就绪, +显示红色三角感叹号及脱敏错误详情,保留启用配置,可在详情中重新启动或切换版本。连续故障受重启次数限制 + +能力响应无法解码只使当前调用失败;上游 429、用户请求错误和插件主动拒绝请求等业务结果不会直接停止插件。单个插件故障不撤销整个宿主的运行配置;命中故障插件绑定的请求仍遵循既有拒绝或委托策略,包括全局 HTTP 绑定覆盖的管理请求。已进入执行集合的入口认证插件故障不会降级到其他认证方式 插件中间件达到调用容量或排队上限时,HTTP 响应为 503(`middleware_capacity_unavailable`),不会跳过插件或重放请求。 应结合插件耗时和并发负载排查;会话预算与作者侧配置见 [SDK 并发与期限](../backend/crates/gateway-plugin/sdk/README.md#并发与期限) diff --git a/frontend/src/api/modules/plugins/index.ts b/frontend/src/api/modules/plugins/index.ts index 74e62e69f..e8753545f 100644 --- a/frontend/src/api/modules/plugins/index.ts +++ b/frontend/src/api/modules/plugins/index.ts @@ -250,7 +250,15 @@ export interface PluginApiDeprecation { migration: string } +export interface PluginCompatibilityConfirmation { + hostFingerprint: string + instanceId: string + instanceRevision: number + artifactSha256: string +} + export interface ConfigurePluginInstanceRequest { + compatibilityConfirmation?: PluginCompatibilityConfirmation creationId?: string expectedRevision?: number replaceInstances?: { id: string, expectedRevision: number }[] @@ -507,6 +515,9 @@ export function deletePluginInstance(data: PluginInstanceIdParam, options: Reque } export interface PluginVersionPlan { + compatibilityWarning: string | null + compatibilityConfirmation: PluginCompatibilityConfirmation | null + loadError: string | null instanceRevision: number artifactSha256: string configuration: Record diff --git a/frontend/src/i18n/locales/en/plugins.json b/frontend/src/i18n/locales/en/plugins.json index c53acbb1b..6404a6fee 100644 --- a/frontend/src/i18n/locales/en/plugins.json +++ b/frontend/src/i18n/locales/en/plugins.json @@ -162,7 +162,10 @@ "disableDescription": "These configurations will stop receiving new requests, settings, secrets, and private data are kept", "willBeDeactivated": "Will be deactivated", "confirmSwitch": "Confirm switch", - "confirmEnable": "Confirm enable" + "confirmEnable": "Confirm enable", + "incompatibleRisk": "Starting an incompatible version may make the system unstable — continue?", + "confirmationScope": "This confirmation applies only to this start, compatibility is checked again when the host starts", + "continueStartup": "Start anyway" }, "extensions": { "emptyTitle": "No plugin management pages", @@ -419,14 +422,15 @@ "runFailed": "Run failed", "compatibilityReminder": "Compatibility reminder", "deprecatedApi": "API upgrade needed, currently compatible", - "startupUnverified": "Startup can be attempted, runtime behavior still needs verification", + "startupUnverified": "Automatic startup is blocked, confirm the risk to try starting it from the plugins page", "compatibilityWindow": "The old API remains supported for at least {remainingReleases} stable releases", "compatibilityEnding": "The compatibility window has ended, a future stable release will remove the old API adapter", "unsupportedVersionHelp": "Configuration and data are kept, install a supported plugin version", "restartHelp": "The enabled configuration is kept, restart it in plugin details", "upgradeHelp": "Update the plugin or ask its author to upgrade the API", "unknown": "Runtime status unknown", - "unknownDescription": "Runtime status is unavailable, the enabled configuration is unchanged" + "unknownDescription": "Runtime status is unavailable, the enabled configuration is unchanged", + "incompatibleNotStarted": "Not started: incompatible version" }, "updates": { "packageRequired": "Select a plugin package to confirm its version and compatibility", diff --git a/frontend/src/i18n/locales/en/systemUpdate.json b/frontend/src/i18n/locales/en/systemUpdate.json index 25103a052..44e36f955 100644 --- a/frontend/src/i18n/locales/en/systemUpdate.json +++ b/frontend/src/i18n/locales/en/systemUpdate.json @@ -69,9 +69,9 @@ }, "plugins": { "title": "Plugin compatibility notice", - "description": "Keep enabled configurations and try starting each plugin after restarting", + "description": "Incompatible plugins will remain stopped", "incompatible": "Incompatible plugins", - "requestErrorHelp": "If requests fail even after plugins start successfully, try disabling the relevant plugins and retrying" + "requestErrorHelp": "Data and settings are kept, retry from the plugins page" }, "versionAfterRestart": "Version after restart", "confirmation": { diff --git a/frontend/src/i18n/locales/zh-Hans/plugins.json b/frontend/src/i18n/locales/zh-Hans/plugins.json index e6d76da71..7e1858527 100644 --- a/frontend/src/i18n/locales/zh-Hans/plugins.json +++ b/frontend/src/i18n/locales/zh-Hans/plugins.json @@ -162,7 +162,10 @@ "disableDescription": "以下配置将停止处理新请求,设置、密钥与私有数据保留", "willBeDeactivated": "将停用", "confirmSwitch": "确认切换", - "confirmEnable": "确认启用" + "confirmEnable": "确认启用", + "incompatibleRisk": "启动不兼容版本可能导致系统不稳定,是否继续?", + "confirmationScope": "确认仅对本次启动有效,下次宿主启动时会重新检查", + "continueStartup": "继续启动" }, "extensions": { "emptyTitle": "暂无插件管理页面", @@ -419,14 +422,15 @@ "runFailed": "运行失败", "compatibilityReminder": "兼容性提醒", "deprecatedApi": "接口待升级,当前仍兼容", - "startupUnverified": "可以尝试启动,运行结果仍需验证", + "startupUnverified": "默认不自动启动,可在插件页确认风险后尝试启动", "compatibilityWindow": "旧接口还将兼容至少 {remainingReleases} 个正式版本", "compatibilityEnding": "兼容窗口已满,后续正式版本将移除旧接口转换", "unsupportedVersionHelp": "配置与数据保留,请安装可加载的插件版本", "restartHelp": "启用配置保留,可在插件详情中重新启动", "upgradeHelp": "请更新插件,或联系作者升级接口", "unknown": "运行状态未知", - "unknownDescription": "暂时无法读取运行状态,启用配置保持不变" + "unknownDescription": "暂时无法读取运行状态,启用配置保持不变", + "incompatibleNotStarted": "未启动:版本不兼容" }, "updates": { "packageRequired": "请选择插件包以确认版本与兼容性", diff --git a/frontend/src/i18n/locales/zh-Hans/systemUpdate.json b/frontend/src/i18n/locales/zh-Hans/systemUpdate.json index e8bb43640..b1a085746 100644 --- a/frontend/src/i18n/locales/zh-Hans/systemUpdate.json +++ b/frontend/src/i18n/locales/zh-Hans/systemUpdate.json @@ -69,9 +69,9 @@ }, "plugins": { "title": "插件兼容性提醒", - "description": "重启后保留启用配置,逐个尝试启动插件", + "description": "重启后,不兼容插件将保持未启动", "incompatible": "不兼容插件", - "requestErrorHelp": "插件启动成功后仍可能出现请求错误,可尝试禁用相关插件后重试" + "requestErrorHelp": "配置和数据保留,可在插件页尝试启动" }, "versionAfterRestart": "重启后版本", "confirmation": { diff --git a/frontend/src/i18n/locales/zh-Hant/plugins.json b/frontend/src/i18n/locales/zh-Hant/plugins.json index 15aad6020..260820fa9 100644 --- a/frontend/src/i18n/locales/zh-Hant/plugins.json +++ b/frontend/src/i18n/locales/zh-Hant/plugins.json @@ -162,7 +162,10 @@ "disableDescription": "以下配置將停止處理新請求,設置、密鑰與私有數據保留", "willBeDeactivated": "將停用", "confirmSwitch": "確認切換", - "confirmEnable": "確認啓用" + "confirmEnable": "確認啓用", + "incompatibleRisk": "啟動不相容版本可能導致系統不穩定,是否繼續?", + "confirmationScope": "確認僅對本次啟動有效,下次宿主啟動時會重新檢查", + "continueStartup": "繼續啟動" }, "extensions": { "emptyTitle": "暫無插件管理頁面", @@ -419,14 +422,15 @@ "runFailed": "運行失敗", "compatibilityReminder": "兼容性提醒", "deprecatedApi": "接口待升級,當前仍兼容", - "startupUnverified": "可以嘗試啓動,運行結果仍需驗證", + "startupUnverified": "預設不自動啟動,可在插件頁確認風險後嘗試啟動", "compatibilityWindow": "舊接口還將兼容至少 {remainingReleases} 個正式版本", "compatibilityEnding": "兼容窗口已滿,後續正式版本將移除舊接口轉換", "unsupportedVersionHelp": "配置與數據保留,請安裝可加載的插件版本", "restartHelp": "啓用配置保留,可在插件詳情中重新啓動", "upgradeHelp": "請更新插件,或聯繫作者升級接口", "unknown": "運行狀態未知", - "unknownDescription": "暫時無法讀取運行狀態,啟用配置保持不變" + "unknownDescription": "暫時無法讀取運行狀態,啟用配置保持不變", + "incompatibleNotStarted": "未啟動:版本不相容" }, "updates": { "packageRequired": "請選擇插件包以確認版本與兼容性", diff --git a/frontend/src/i18n/locales/zh-Hant/systemUpdate.json b/frontend/src/i18n/locales/zh-Hant/systemUpdate.json index 4fa45a0e2..c48988fdc 100644 --- a/frontend/src/i18n/locales/zh-Hant/systemUpdate.json +++ b/frontend/src/i18n/locales/zh-Hant/systemUpdate.json @@ -69,9 +69,9 @@ }, "plugins": { "title": "插件兼容性提醒", - "description": "重啓後保留啓用配置,逐個嘗試啓動插件", + "description": "重新啟動後,不相容插件將維持未啟動", "incompatible": "不兼容插件", - "requestErrorHelp": "插件啓動成功後仍可能出現請求錯誤,可嘗試禁用相關插件後重試" + "requestErrorHelp": "設定與資料保留,可在插件頁嘗試啟動" }, "versionAfterRestart": "重啓後版本", "confirmation": { diff --git a/frontend/src/views/plugins/components/PluginCatalogPanel.vue b/frontend/src/views/plugins/components/PluginCatalogPanel.vue index 699796d38..8676165f7 100644 --- a/frontend/src/views/plugins/components/PluginCatalogPanel.vue +++ b/frontend/src/views/plugins/components/PluginCatalogPanel.vue @@ -58,6 +58,9 @@ const rows = computed(() => filtered.value const version = plugin.artifacts.find(artifact => artifact.metadata.sha256 === current?.artifactSha256)?.metadata.version ?? plugin.artifact.metadata.version return { ...plugin, + failureReason: plugin.configurations.filter(instance => instance.enabled).length > 1 + ? t('plugins.configuration.legacy.multipleEnabledNotice') + : undefined, versionLabel: version, versionTitle: `${current ? t('plugins.catalog.currentVersion') @@ -139,8 +142,8 @@ const columns = computed(() => (defineTableColumns<(typeof rows.value)[number]>( {{ plugin.artifact.metadata.publisher }} - - + + {{ PLUGIN_STATUS_LABELS[pluginStatus(plugin)] }} @@ -175,8 +178,8 @@ const columns = computed(() => (defineTableColumns<(typeof rows.value)[number]>(