fix(plugins): gate incompatible plugin startup

Keep incompatible plugins out of execution sets during automatic startup.
Require scoped confirmation for manual compatibility overrides and show
the stopped state and upgrade effects in the management UI.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-10-27 12:37:28 +08:00
co-authored by Codex
parent 1db8b0e319
commit e33f50ec09
43 changed files with 965 additions and 232 deletions
@@ -152,6 +152,8 @@ pub struct PluginInstanceView {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ConfigurePluginInstance {
/// 只随本次启动请求传递,不写入实例配置
pub compatibility_confirmation: Option<PluginCompatibilityConfirmation>,
/// 创建草稿固定一个 UUID,重试复用同一配置,不能作为编辑已有配置的入口
pub creation_id: Option<String>,
/// 编辑时检查用户看到的配置版本,避免覆盖另一窗口已经保存的内容
@@ -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<String>,
pub compatibility_confirmation: Option<PluginCompatibilityConfirmation>,
pub load_error: Option<String>,
pub instance_revision: u64,
pub artifact_sha256: String,
pub configuration: serde_json::Value,
@@ -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<String> {
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 {
@@ -46,6 +46,15 @@ pub trait PluginPreparation: PluginRuntimeDiagnostics + PluginStateLifecycle {
&self,
snapshot: PluginInstanceSnapshot,
) -> Result<ExtensionSetReference, AdminError>;
/// 确认由 Admin 校验配置版本,Runtime 复核宿主并仅放行目标实例
async fn prepare_confirmed(
&self,
_snapshot: PluginInstanceSnapshot,
_confirmation: crate::model::plugins::instances::PluginCompatibilityConfirmation,
) -> Result<ExtensionSetReference, AdminError> {
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<String, AdminError> {
Err(AdminError::internal("宿主兼容声明不可用"))
}
/// 完整校验包并返回版本范围诊断;校验失败与可加载但未经宿主承诺的提醒分开
async fn compatibility_warning(
&self,
@@ -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)),
}
@@ -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(),
@@ -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<PluginInstanceMutation, AdminError> {
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>(())
}
@@ -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)
}
@@ -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<ExtensionSetReference, AdminError> {
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)
@@ -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(),
@@ -39,6 +39,7 @@ struct FixtureData {
artifact: InstalledPluginArtifact,
snapshot: PluginInstanceSnapshot,
saves: usize,
compatibility_warning: Option<String>,
publications: Vec<u64>,
}
@@ -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<Option<String>, 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());
}
@@ -63,6 +63,10 @@ enum MigrationBehavior {
#[async_trait]
impl gateway_admin::ports::plugins::PluginPackageInspector for LifecycleFixture {
fn compatibility_host_fingerprint(&self) -> Result<String, AdminError> {
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<ExtensionSetReference, AdminError> {
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);
}
}
@@ -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<AdminError> for InstancePreparationError {
fn from(error: AdminError) -> Self {
Self::Failed(error)
}
}
struct StartedInstance {
instance: PluginInstance,
package: Arc<crate::PreparedPackage>,
@@ -31,16 +56,9 @@ impl PluginRuntime {
instance: PluginInstance,
target_revision: Revision,
request_body_limit: Option<usize>,
) -> Result<StartedInstance, AdminError> {
allow_incompatible: bool,
) -> Result<StartedInstance, InstancePreparationError> {
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<usize>,
) -> Result<Arc<PreparedInstance>, AdminError> {
allow_incompatible: bool,
) -> Result<Arc<PreparedInstance>, 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(
@@ -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,
@@ -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<Revision>,
request_body_limit: Option<usize>,
confirmation: Option<&PluginCompatibilityConfirmation>,
) -> Result<ExtensionSetReference, AdminError> {
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<Revision>,
request_body_limit: Option<usize>,
confirmation: Option<&PluginCompatibilityConfirmation>,
) -> Result<ExtensionSetReference, AdminError> {
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<ExtensionSetReference, AdminError> {
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<ExtensionSetReference, AdminError> {
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)
})
@@ -69,19 +69,16 @@ pub(crate) fn warning(
requirements: &PluginCompatibilityRequirements,
host: &semver::Version,
) -> Result<Option<String>, 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<String, AdminError> {
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,
))))
}
@@ -120,6 +120,10 @@ impl PackageInspector {
#[async_trait]
impl PluginPackageInspector for PackageInspector {
fn compatibility_host_fingerprint(&self) -> Result<String, AdminError> {
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("插件制品缓存暂不可用"),
}
}
@@ -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};
@@ -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<crate::support::store::Store>,
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]
@@ -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
);
}
}
}
@@ -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 支持的合同
## 接口弃用
+5 -2
View File
@@ -716,11 +716,14 @@ Release 必须提供当前 OS/架构的 `codex-proxy-rs_<version>_<os>_<arch>.ta
| `plugin-release-manifest.json` | 固定宿主版本、提交、平台与支持的插件合同;插件列表当前为空 |
| 可执行文件同目录的 `plugins/official` | 随受信宿主发行物部署的只读目录,与二进制、Web 资源成套更新或恢复 |
- 下载更新时校验发行身份,插件合同不兼容不阻止安装。点击重启时检查已安装目标版本,列出不兼容插件的名称和原因;确认后保留启用配置并重启,逐个尝试启动插件,版本警告持续显示
- 回滚同样在重启前提示插件兼容风险,不因版本不匹配批量停用插件。更新或回滚期间的并发配置变更会阻止切换,文件替换失败或取消时恢复整组文件
- 下载更新时校验发行身份,插件合同不兼容不阻止安装。点击重启时检查已安装目标版本,列出不兼容插件的名称和原因;确认后重启,不兼容实例保持未启动,启用配置、绑定、密钥和私有数据保留
- 回滚同样在重启前提示插件兼容风险,启动时重新检查,不改写持久启用配置。更新或回滚期间的并发配置变更会阻止切换,文件替换失败或取消时恢复整组文件
- 非空官方清单中的包经身份、平台和摘要校验后幂等导入;导入不确认信任、不创建配置、不启用,也不删除旧包。管理员确认信任后才执行默认配置流程;重复摘要保留首次安装出处
- `sealed` 是构建封口标记,不是密码学签名。普通上传、URL 或 GitHub 安装不能获得 `builtin` 身份,独立教学示例也不例外
宿主启动检查覆盖管理页更新、Docker 与手工替换二进制。不兼容实例不挂载任何能力或故障占位,其额外认证和拦截停止生效,宿主原生认证继续执行。
宿主范围或能力版本不匹配时,可在插件页确认风险后尝试本次启动;清单、RPC、平台或包完整性错误不能绕过。具体操作见[插件故障处理](../docs/plugins.md#故障处理)
部署须保留完整的二进制、Web 资源与官方插件目录;不要手动修改制品元数据或迁移 checksum 绕过校验。
macOS arm64 提供构建产物;部署前需验证目标平台的插件运行与上游调用,不能只以打包成功判断可用
+14 -7
View File
@@ -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,停用草稿不覆盖快照。
+7 -4
View File
@@ -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 无法确认时仍停止新请求
+11 -9
View File
@@ -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#并发与期限)
+11
View File
@@ -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<string, unknown>
+7 -3
View File
@@ -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",
@@ -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": {
@@ -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": "请选择插件包以确认版本与兼容性",
@@ -69,9 +69,9 @@
},
"plugins": {
"title": "插件兼容性提醒",
"description": "重启后保留启用配置,逐个尝试启动插件",
"description": "重启后,不兼容插件将保持未启动",
"incompatible": "不兼容插件",
"requestErrorHelp": "插件启动成功后仍可能出现请求错误,可尝试禁用相关插件后重试"
"requestErrorHelp": "配置和数据保留,可在插件页尝试启动"
},
"versionAfterRestart": "重启后版本",
"confirmation": {
@@ -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": "請選擇插件包以確認版本與兼容性",
@@ -69,9 +69,9 @@
},
"plugins": {
"title": "插件兼容性提醒",
"description": "重啓後保留啓用配置,逐個嘗試啓動插件",
"description": "重新啟動後,不相容插件將維持未啟動",
"incompatible": "不兼容插件",
"requestErrorHelp": "插件啓動成功後仍可能出現請求錯誤,可嘗試禁用相關插件後重試"
"requestErrorHelp": "設定與資料保留,可在插件頁嘗試啟動"
},
"versionAfterRestart": "重啓後版本",
"confirmation": {
@@ -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]>(
</div>
<span class="truncate text-cp-xs text-cp-text-secondary">{{ plugin.artifact.metadata.publisher }}</span>
</div>
<PluginStatusNotice v-if="currentPluginInstance(plugin)" :instance="currentPluginInstance(plugin)!" />
<ZTag :type="pluginStatusType(pluginStatus(plugin))" size="small">
<PluginStatusNotice v-if="currentPluginInstance(plugin)" :instance="currentPluginInstance(plugin)!" :failure-reason="plugin.failureReason" />
<ZTag v-if="!['failed', 'incompatible'].includes(pluginStatus(plugin))" :type="pluginStatusType(pluginStatus(plugin))" size="small">
{{ PLUGIN_STATUS_LABELS[pluginStatus(plugin)] }}
</ZTag>
</div>
@@ -175,8 +178,8 @@ const columns = computed(() => (defineTableColumns<(typeof rows.value)[number]>(
</template>
<template #status="{ row }">
<div class="flex items-center gap-1.5">
<PluginStatusNotice v-if="currentPluginInstance(row)" :instance="currentPluginInstance(row)!" />
<ZTag :type="pluginStatusType(pluginStatus(row))">
<PluginStatusNotice v-if="currentPluginInstance(row)" :instance="currentPluginInstance(row)!" :failure-reason="row.failureReason" />
<ZTag v-if="!['failed', 'incompatible'].includes(pluginStatus(row))" :type="pluginStatusType(pluginStatus(row))">
{{ PLUGIN_STATUS_LABELS[pluginStatus(row)] }}
</ZTag>
</div>
@@ -109,7 +109,7 @@ watch(() => props.initialSection, value => section.value = value)
<strong class="min-w-0 flex-1 wrap-break-word text-cp-sm">{{ t('plugins.catalog.currentVersion') }}</strong>
<ZTag>{{ artifactForInstance(instance, plugin.artifacts)?.metadata.version ?? t('plugins.details.versionNotAvailable') }}</ZTag>
<PluginStatusNotice :instance="instance" />
<ZTag :type="pluginStatusType(configurationStatus(instance))">
<ZTag v-if="!['failed', 'incompatible'].includes(configurationStatus(instance))" :type="pluginStatusType(configurationStatus(instance))">
{{ PLUGIN_STATUS_LABELS[configurationStatus(instance)] }}
</ZTag>
<PluginHelpPopover v-if="configurationNotes(instance).length" :label="t('plugins.details.configurationStatusOf', { name: instance.name })">
@@ -134,7 +134,7 @@ watch(() => props.initialSection, value => section.value = value)
<ZIconButton v-if="instance.configurationRequired || (currentArtifact && hasPluginSettings(currentArtifact))" size="small" variant="solid" :aria-label="t('plugins.details.settings')" :disabled="busy" @click="$emit('edit', instance)">
<Settings2 class="size-4" />
</ZIconButton>
<ZIconButton v-if="configurationStatus(instance) === 'failed'" :aria-label="t('plugins.details.restart')" size="small" variant="solid" :disabled="busy || Boolean(instance.loadError)" @click="$emit('enable', instance)">
<ZIconButton v-if="['failed', 'incompatible'].includes(configurationStatus(instance))" :aria-label="t('plugins.details.restart')" size="small" variant="solid" :disabled="busy || Boolean(instance.loadError)" @click="$emit('enable', instance)">
<RefreshCw class="size-4" />
</ZIconButton>
<ZIconButton v-if="instance.enabled" :aria-label="t('plugins.details.deactivate')" size="small" variant="solid" :disabled="busy" @click="$emit('disable', instance)">
@@ -12,6 +12,7 @@ const props = defineProps<{
request?: ConfigurePluginInstanceRequest
metadata?: PluginArtifactMetadata
replacements: (Pick<PluginInstance, 'id' | 'name'> & { version?: string })[]
compatibilityWarning?: string | null
saving: boolean
}>()
@@ -37,8 +38,19 @@ const parameters = computed(() => {
</script>
<template>
<ZDialog v-model="open" :title="replacements.length ? t('plugins.enable.switchActiveConfiguration') : t('plugins.enable.enablePluginConfiguration')" width="36rem" :show-close="!saving" :close-on-click-modal="!saving" :close-on-press-escape="!saving" @closed="$emit('closed')">
<ZDialog v-model="open" :title="replacements.length ? t('plugins.enable.switchActiveConfiguration') : t('plugins.enable.enablePluginConfiguration')" :type="compatibilityWarning ? 'warning' : 'default'" width="36rem" :show-close="!saving" :close-on-click-modal="!saving" :close-on-press-escape="!saving" @closed="$emit('closed')">
<div v-if="request" class="grid gap-4">
<div v-if="compatibilityWarning" role="alert" class="grid gap-2 rounded-cp bg-cp-fill-alter p-4 text-cp-sm">
<p class="m-0 font-emphasis text-cp-warning">
{{ t('plugins.enable.incompatibleRisk') }}
</p>
<p class="m-0 wrap-anywhere text-cp-text-secondary">
{{ compatibilityWarning }}
</p>
<p class="m-0 text-cp-text-secondary">
{{ t('plugins.enable.confirmationScope') }}
</p>
</div>
<div class="grid gap-3 rounded-cp bg-cp-fill-alter p-4">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<strong class="min-w-0 flex-1 wrap-anywhere text-cp-sm">{{ request.name }}</strong>
@@ -88,7 +100,7 @@ const parameters = computed(() => {
{{ t('common.actions.cancel') }}
</ZButton>
<ZButton type="primary" :loading="saving" :disabled="!request" @click="$emit('confirm')">
{{ replacements.length ? t('plugins.enable.confirmSwitch') : t('plugins.enable.confirmEnable') }}
{{ compatibilityWarning ? t('plugins.enable.continueStartup') : replacements.length ? t('plugins.enable.confirmSwitch') : t('plugins.enable.confirmEnable') }}
</ZButton>
</template>
</ZDialog>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ZIconButton, ZPopover } from '@codex-proxy/ui'
import { CircleAlert, Info, TriangleAlert } from '@lucide/vue'
import { Info, TriangleAlert } from '@lucide/vue'
import { shallowRef, useId } from 'vue'
defineProps<{ label: string, tone?: 'warning' | 'error' }>()
@@ -30,8 +30,7 @@ function handleKeydown(event: KeyboardEvent) {
:aria-describedby="open ? descriptionId : undefined"
@keydown="handleKeydown"
>
<CircleAlert v-if="tone === 'error'" class="size-4 text-cp-error-text" />
<TriangleAlert v-else-if="tone === 'warning'" class="size-4 text-cp-warning" />
<TriangleAlert v-if="tone" class="size-4" :class="tone === 'error' ? 'text-cp-error-text' : 'text-cp-warning'" />
<Info v-else class="size-3.5" />
</ZIconButton>
</template>
@@ -38,7 +38,7 @@ const { t } = useI18n({ useScope: 'global' })
{{ artifactForInstance(instance, artifacts)?.metadata.version }}
</ZTag>
<PluginStatusNotice :instance="instance" />
<ZTag size="small" :type="pluginStatusType(configurationStatus(instance))">
<ZTag v-if="!['failed', 'incompatible'].includes(configurationStatus(instance))" size="small" :type="pluginStatusType(configurationStatus(instance))">
{{ PLUGIN_STATUS_LABELS[configurationStatus(instance)] }}
</ZTag>
<div class="flex shrink-0 items-center gap-1">
@@ -169,6 +169,7 @@ const tabOptions = computed(() => ([
<PluginEnableModal
v-model="management.showEnableConfirmation.value"
:request="management.pendingEnable.value?.request"
:compatibility-warning="management.pendingEnable.value?.compatibilityWarning"
:metadata="management.pendingEnable.value?.artifact.metadata"
:replacements="management.pendingEnable.value?.replacements ?? []"
:saving="management.savingInstance.value"
@@ -3,38 +3,52 @@ import type { PluginInstance } from '@/api'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { PLUGIN_STATUS_LABELS } from '../constants'
import { configurationStatus } from '../utils/catalog'
import PluginHelpPopover from './PluginHelpPopover.vue'
const props = defineProps<{ instance: PluginInstance }>()
const props = defineProps<{ instance: PluginInstance, failureReason?: string }>()
const { t } = useI18n({ useScope: 'global' })
const failure = computed(() => props.instance.loadError ?? props.instance.runtime.failure?.message)
const title = computed(() => failure.value
? (props.instance.loadError
const status = computed(() => configurationStatus(props.instance))
const failure = computed(() => props.failureReason ?? props.instance.loadError ?? props.instance.runtime.failure?.message)
const incompatible = computed(() => !props.failureReason && status.value === 'incompatible')
const tone = computed(() => (failure.value || status.value === 'failed') && !incompatible.value
? 'error'
: 'warning')
const title = computed(() => {
if (props.failureReason)
return PLUGIN_STATUS_LABELS.value.failed
if (incompatible.value)
return t('plugins.status.incompatibleNotStarted')
if (failure.value || status.value === 'failed') {
return props.instance.loadError
? t('plugins.status.unableToStart')
: t('plugins.status.runFailed'))
: props.instance.compatibilityWarning
: t('plugins.status.runFailed')
}
return props.instance.compatibilityWarning
? t('plugins.status.compatibilityReminder')
: t('plugins.status.deprecatedApi'))
: t('plugins.status.deprecatedApi')
})
</script>
<template>
<PluginHelpPopover
v-if="failure || instance.compatibilityWarning || instance.apiDeprecations.length"
v-if="failure || status === 'failed' || instance.compatibilityWarning || instance.apiDeprecations.length"
:label="`${instance.name}:${title}`"
:tone="failure ? 'error' : 'warning'"
:tone="tone"
>
<p class="m-0 font-emphasis text-cp-text">
{{ title }}
</p>
<p v-if="failure" class="m-0 text-cp-error-text">
<p v-if="failure" class="m-0" :class="tone === 'error' ? 'text-cp-error-text' : 'text-cp-warning'">
{{ failure }}
</p>
<p v-if="instance.runtime.failure" class="m-0 font-mono">
<p v-if="instance.runtime.failure && !incompatible" class="m-0 font-mono">
{{ instance.runtime.failure.code }}
</p>
<template v-if="instance.compatibilityWarning">
<template v-if="instance.compatibilityWarning && !incompatible">
<p class="m-0 text-cp-warning">
{{ instance.compatibilityWarning }}
</p>
@@ -56,7 +70,7 @@ const title = computed(() => failure.value
<p v-if="instance.loadError" class="m-0">
{{ t('plugins.status.unsupportedVersionHelp') }}
</p>
<p v-else-if="failure" class="m-0">
<p v-else-if="(failure || status === 'failed') && !instance.compatibilityWarning" class="m-0">
{{ t('plugins.status.restartHelp') }}
</p>
<p v-else-if="instance.apiDeprecations.length" class="m-0">
@@ -37,7 +37,7 @@ const detailsId = useId()
<strong class="min-w-0 flex-1 wrap-break-word text-cp-sm">{{ t('plugins.catalog.currentVersion') }}</strong>
<ZTag>{{ artifact.metadata.version }}</ZTag>
<PluginStatusNotice :instance="current" />
<ZTag :type="pluginStatusType(configurationStatus(current))">
<ZTag v-if="!['failed', 'incompatible'].includes(configurationStatus(current))" :type="pluginStatusType(configurationStatus(current))">
{{ PLUGIN_STATUS_LABELS[configurationStatus(current)] }}
</ZTag>
</div>
@@ -1,8 +1,8 @@
import type { Ref } from 'vue'
import type { PluginRefreshContext } from '../utils/actions'
import type { ConfigurePluginInstanceRequest, PluginArtifact, PluginInstance, PluginVersionPlan } from '@/api'
import type { ConfigurePluginInstanceRequest, PluginArtifact, PluginCompatibilityConfirmation, PluginInstance, PluginVersionPlan } from '@/api'
import { shallowRef } from 'vue'
import { deletePluginInstance, disablePluginInstance, getPluginArtifacts, getPluginInstances, updatePluginInstance } from '@/api'
import { deletePluginInstance, disablePluginInstance, getPluginArtifacts, getPluginInstances, getPluginVersionPlan, updatePluginInstance } from '@/api'
import { useAsyncAction } from '@/composables/useAsyncAction'
import { t } from '@/i18n'
import { notification as ZNotification } from '@/utils/notification'
@@ -28,6 +28,8 @@ export function usePluginInstances({ artifacts, refresh, onEdit, onSaved }: Inst
const savingInstance = action.loading
const pendingEnable = shallowRef<{
request: ConfigurePluginInstanceRequest
compatibilityWarning: string | null
compatibilityConfirmation: PluginCompatibilityConfirmation | null
instanceId?: string
artifact: PluginArtifact
replacements: (Pick<PluginInstance, 'id' | 'name' | 'revision'> & { version?: string })[]
@@ -56,7 +58,7 @@ export function usePluginInstances({ artifacts, refresh, onEdit, onSaved }: Inst
const result = await action.run(async () => {
if (request.enabled) {
const pending = await prepareInstanceEnable(input, instance.id)
if (pending.replacements.length) {
if (pending.replacements.length || pending.compatibilityWarning) {
pendingEnable.value = pending
showEnableConfirmation.value = true
return true
@@ -69,12 +71,17 @@ export function usePluginInstances({ artifacts, refresh, onEdit, onSaved }: Inst
await refresh(true, true)
}
async function prepareInstanceEnable(request: ConfigurePluginInstanceRequest, instanceId?: string) {
async function prepareInstanceEnable(request: ConfigurePluginInstanceRequest, instanceId: string) {
// 启用入口共用确认快照,不自动停用确认后出现或修改的配置。
const [currentArtifacts, currentInstances] = await Promise.all([
const [currentArtifacts, currentInstances, plan] = await Promise.all([
getPluginArtifacts({ silent: true }),
getPluginInstances({ silent: true }),
getPluginVersionPlan({ id: instanceId, artifactSha256: request.artifactSha256 }, { silent: true }),
])
if (plan.instanceRevision !== request.expectedRevision || (plan.compatibilityWarning && !plan.compatibilityConfirmation))
throw new Error(t('plugins.configuration.stale'))
if (plan.loadError)
throw new Error(plan.loadError)
const artifact = currentArtifacts.find(artifact => artifact.metadata.sha256 === request.artifactSha256)
if (!artifact)
throw new Error(t('plugins.instances.versionMissing'))
@@ -89,6 +96,8 @@ export function usePluginInstances({ artifacts, refresh, onEdit, onSaved }: Inst
}))
return {
request,
compatibilityWarning: plan.compatibilityWarning,
compatibilityConfirmation: plan.compatibilityConfirmation,
instanceId,
artifact,
replacements,
@@ -100,7 +109,7 @@ export function usePluginInstances({ artifacts, refresh, onEdit, onSaved }: Inst
ZNotification.error({ message: instance.loadError })
return
}
if ((instance.enabled && configurationStatus(instance) !== 'failed') || savingInstance.value || showEnableConfirmation.value)
if ((instance.enabled && !['failed', 'incompatible'].includes(configurationStatus(instance))) || savingInstance.value || showEnableConfirmation.value)
return
if (instance.configurationRequired) {
openEditInstance(instance)
@@ -116,7 +125,7 @@ export function usePluginInstances({ artifacts, refresh, onEdit, onSaved }: Inst
configuration: instance.configuration,
bindings: instance.bindings,
}, instance.id)
if (pending.replacements.length) {
if (pending.replacements.length || pending.compatibilityWarning) {
pendingEnable.value = pending
showEnableConfirmation.value = true
}
@@ -138,6 +147,7 @@ export function usePluginInstances({ artifacts, refresh, onEdit, onSaved }: Inst
const result = await action.run(async () => {
await persistInstance({
...pending.request,
compatibilityConfirmation: pending.compatibilityConfirmation ?? undefined,
replaceInstances: pending.replacements.map(instance => ({ id: instance.id, expectedRevision: instance.revision })),
}, pending.instanceId)
return true
@@ -48,6 +48,16 @@ export function usePluginVersions({ catalog, instances, busyInstanceId, refresh,
async function applyVersionSwitch(instance: PluginInstance, artifact: PluginArtifact) {
busyInstanceId.value = instance.id
try {
const plan = await getPluginVersionPlan({ id: instance.id, artifactSha256: artifact.metadata.sha256 }, { silent: true })
if (plan.instanceRevision !== instance.revision)
throw new Error(t('plugins.configuration.stale'))
if (plan.loadError)
throw new Error(plan.loadError)
if (instance.enabled && plan.compatibilityWarning) {
showVersionSwitch.value = false
onConfigurationRequired(instance, artifact, plan, plan.compatibilityWarning)
return
}
await switchPluginVersion({
id: instance.id,
target: { artifactSha256: artifact.metadata.sha256, expectedRevision: instance.revision },
+1
View File
@@ -88,5 +88,6 @@ export const PLUGIN_STATUS_LABELS = computed<Record<PluginCatalogStatus, string>
disabled: t('plugins.fields.deactivated'),
pending: t('plugins.fields.waitingForItToTakeEffect'),
failed: t('common.status.error'),
incompatible: t('plugins.status.incompatibleNotStarted'),
unknown: t('plugins.status.unknown'),
}))
+4 -2
View File
@@ -8,7 +8,7 @@ export interface InstalledPlugin {
source?: PluginUpdateSourceBinding
}
export type PluginCatalogStatus = 'unaccepted' | 'unconfigured' | 'enabled' | 'disabled' | 'pending' | 'failed' | 'unknown'
export type PluginCatalogStatus = 'unaccepted' | 'unconfigured' | 'enabled' | 'disabled' | 'pending' | 'failed' | 'unknown' | 'incompatible'
export function groupInstalledPlugins(artifacts: PluginArtifact[], instances: PluginInstance[], sources: PluginUpdateSourceBinding[]): InstalledPlugin[] {
const groups = new Map<string, InstalledPlugin>()
@@ -45,6 +45,8 @@ export function groupInstalledPlugins(artifacts: PluginArtifact[], instances: Pl
}
export function configurationStatus(instance: PluginInstance): PluginCatalogStatus {
if (instance.runtime.failure?.code === 'version_incompatible')
return 'incompatible'
if (instance.loadError)
return 'failed'
if (instance.configurationRequired)
@@ -76,7 +78,7 @@ export function pluginStatusType(status: PluginCatalogStatus) {
return 'success' as const
if (status === 'failed')
return 'danger' as const
if (status === 'pending' || status === 'unconfigured' || status === 'unaccepted')
if (status === 'incompatible' || status === 'pending' || status === 'unconfigured' || status === 'unaccepted')
return 'warning' as const
return 'default' as const
}