flare-plugin-host 0.1.0

Flare 能力插件的宿主接入库:声明、注册、注销
Documentation
//! Flare 能力插件的**宿主接入库**:把插件向 capability 声明自己这件事做成三行。
//!
//! # 它解决什么
//!
//! 插件接入 capability 有两条路:
//!
//! 1. **被发现**:插件注册到 Consul/etcd,capability 按配置里的
//!    `plugin_discovery_endpoints` 找到它。这条路走得通,但发现来的端点
//!    **没有清单信息** —— capability 只能把它标成 `unverified`,
//!    于是「声明即边界」对它无从强制。
//! 2. **主动声明**(本库):插件启动后调用 `RegisterPluginEndpoint`,
//!    带上版本、清单摘要与自己声明能处理的 operation 全集。
//!    这样它才是 verified 的,声明之外的调用会被 capability 拒绝。
//!
//! 两条路不冲突:服务目录负责「进程在哪」,声明负责「它能做什么」。
//! 本库只做后者 —— 前者各插件已经在用 `flare_server_core::discovery`,
//! 没必要再包一层。
//!
//! # 为什么要有这个库,而不是每个插件自己调
//!
//! 「自己调」的问题不在于代码量,在于**容易少调**:漏了 declared_operations
//! 就退化成 unverified,而这不会报错、只会让边界强制失效。把它做成一个
//! 必须填满字段的结构体,漏填在编译期就暴露。
//!
//! ```no_run
//! use flare_plugin_host::{PluginDeclaration, PluginHost};
//!
//! # async fn demo() -> Result<(), flare_plugin_host::HostError> {
//! let declaration = PluginDeclaration {
//!     tenant_id: "0".into(),
//!     plugin_id: "flare-moments".into(),
//!     capability_id: "social.moments.feed".into(),
//!     grpc_authority: "127.0.0.1:50204".into(),
//!     plugin_version: "1.0.0".into(),
//!     api_version: "1".into(),
//!     manifest_sha256: "….".into(),
//!     declared_operations: vec!["social.moments.feed".into()],
//!     labels: Default::default(),
//!     seat_model: flare_plugin_host::SeatModel::Tenant,
//! };
//! PluginHost::connect("http://127.0.0.1:50051")
//!     .await?
//!     .announce(&declaration)
//!     .await?;
//! # Ok(())
//! # }
//! ```

use std::collections::HashMap;
use std::time::Duration;

use flare_grpc_proto::capability::capability_service_client::CapabilityServiceClient;
use flare_grpc_proto::capability::{
    DeregisterPluginEndpointRequest, RegisterPluginEndpointRequest,
};
use tonic::transport::Channel;

#[derive(Debug, thiserror::Error)]
pub enum HostError {
    #[error("连接 capability 失败:{0}")]
    Connect(String),
    #[error("capability 拒绝了注册:{0}")]
    Rejected(String),
    #[error("gRPC 调用失败:{0}")]
    Rpc(String),
    #[error("声明不完整:{0}")]
    Invalid(&'static str),
}

/// 插件对 capability 的完整声明。
///
/// 字段全部必填是刻意的:这些正是注册契约 v2 里「可选、缺失即降级为
/// unverified」的那些。协议层必须可选(否则核心升级会打死所有旧插件),
/// 但**新写的插件没有理由不填** —— 在这里做成必填,漏填就编译不过。
#[derive(Debug, Clone)]
pub struct PluginDeclaration {
    pub tenant_id: String,
    pub plugin_id: String,
    /// 本次注册的能力 id。必须出现在 `declared_operations` 里,
    /// 否则 capability 会当场拒绝(清单与注册对不上)。
    ///
    /// 它**不限制**插件承接的范围 —— 范围由 `declared_operations` 决定。
    /// 这里填一个最好认的入口即可,它主要出现在路由簿与日志里。
    pub capability_id: String,
    /// 本插件的 gRPC 地址,capability 按它回调。
    pub grpc_authority: String,
    pub plugin_version: String,
    pub api_version: String,
    /// 插件清单(plugin.json)的 sha256,用于确认部署物与目录一致。
    pub manifest_sha256: String,
    /// 本插件能处理的 operation 全集。**留空即退化为 unverified**。
    pub declared_operations: Vec<String>,
    /// 附加标签,例如 `health_protocol` 声明特殊探活协议。
    pub labels: HashMap<String, String>,
    /// 计费/授权单位:`SeatModel::Tenant`(装了全员可用)或
    /// `SeatModel::PerUser`(还需逐人授权)。
    ///
    /// 这是**产品决策**,平台不替插件决定它怎么卖,所以必填。
    pub seat_model: SeatModel,
}

/// 计费/授权单位。
///
/// - `Tenant`:租户装了就全员可用。绝大多数插件属于这一类 ——
///   组织装了大家就用,不该再逐人发放。
/// - `PerUser`:还需逐人授权。留给两类:有边际成本的(AI 按 token 烧钱,
///   按席位卖才不亏)与需合规隔离的(DLP 导出只给特定角色)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SeatModel {
    Tenant,
    PerUser,
}

impl SeatModel {
    fn as_wire(self) -> &'static str {
        match self {
            Self::Tenant => "tenant",
            Self::PerUser => "per_user",
        }
    }
}

impl PluginDeclaration {
    /// 本地自检:把「注册后才发现对不上」提前到调用前。
    ///
    /// capability 侧也会校验(那是权威判定),这里重复一遍是为了让插件作者
    /// 在自己的进程里就看到错误,而不是去翻 capability 的日志。
    pub fn validate(&self) -> Result<(), HostError> {
        if self.plugin_id.trim().is_empty() {
            return Err(HostError::Invalid("plugin_id 不能为空"));
        }
        if self.capability_id.trim().is_empty() {
            return Err(HostError::Invalid("capability_id 不能为空"));
        }
        if self.grpc_authority.trim().is_empty() {
            return Err(HostError::Invalid("grpc_authority 不能为空"));
        }
        if self.declared_operations.is_empty() {
            return Err(HostError::Invalid(
                "declared_operations 为空会让插件退化为 unverified,声明边界无法强制",
            ));
        }
        if !self.declared_operations.contains(&self.capability_id) {
            return Err(HostError::Invalid(
                "capability_id 必须出现在 declared_operations 里",
            ));
        }
        Ok(())
    }
}

/// 与 capability 控制面的连接。
pub struct PluginHost {
    client: CapabilityServiceClient<Channel>,
}

impl PluginHost {
    /// 连接 capability。`endpoint` 形如 `http://host:port`。
    pub async fn connect(endpoint: impl Into<String>) -> Result<Self, HostError> {
        let endpoint = endpoint.into();
        let channel = Channel::from_shared(endpoint.clone())
            .map_err(|e| HostError::Connect(format!("{endpoint}: {e}")))?
            .connect_timeout(Duration::from_secs(5))
            .connect()
            .await
            .map_err(|e| HostError::Connect(format!("{endpoint}: {e}")))?;
        Ok(Self {
            client: CapabilityServiceClient::new(channel),
        })
    }

    /// 向 capability 声明本插件。
    ///
    /// 成功后该实例是 **verified** 的:声明之外的调用会被 capability 拒绝。
    pub async fn announce(&mut self, declaration: &PluginDeclaration) -> Result<(), HostError> {
        declaration.validate()?;

        let response = self
            .client
            .register_plugin_endpoint(RegisterPluginEndpointRequest {
                tenant_id: declaration.tenant_id.clone(),
                plugin_id: declaration.plugin_id.clone(),
                capability_id: declaration.capability_id.clone(),
                grpc_authority: declaration.grpc_authority.clone(),
                labels: declaration.labels.clone(),
                request_id: String::new(),
                plugin_version: declaration.plugin_version.clone(),
                api_version: declaration.api_version.clone(),
                manifest_sha256: declaration.manifest_sha256.clone(),
                declared_operations: declaration.declared_operations.clone(),
                seat_model: declaration.seat_model.as_wire().to_string(),
            })
            .await
            .map_err(|e| HostError::Rpc(e.to_string()))?
            .into_inner();

        if !response.accepted {
            return Err(HostError::Rejected(response.message));
        }
        tracing::info!(
            plugin_id = %declaration.plugin_id,
            capability_id = %declaration.capability_id,
            operations = declaration.declared_operations.len(),
            "plugin announced to capability"
        );
        Ok(())
    }

    /// 优雅摘除:停机前告诉 capability 别再往这里派活。
    ///
    /// 不调用也不会坏 —— capability 的健康检查最终会把它摘掉。但那要等一个
    /// 检查周期,期间的调用会打到正在退出的进程上。
    /// 注销粒度是 `(tenant, plugin)`,与注册粒度一致 —— 一个插件进程在路由簿里
    /// 只有一条记录,承接范围由 `declared_operations` 表达,所以一次注销即摘干净。
    pub async fn withdraw(&mut self, tenant_id: &str, plugin_id: &str) -> Result<(), HostError> {
        self.client
            .deregister_plugin_endpoint(DeregisterPluginEndpointRequest {
                tenant_id: tenant_id.to_string(),
                plugin_id: plugin_id.to_string(),
                request_id: String::new(),
            })
            .await
            .map_err(|e| HostError::Rpc(e.to_string()))?;
        tracing::info!(plugin_id, "plugin withdrawn from capability");
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn declaration() -> PluginDeclaration {
        PluginDeclaration {
            tenant_id: "0".into(),
            plugin_id: "p1".into(),
            capability_id: "vendorx.do".into(),
            grpc_authority: "127.0.0.1:1".into(),
            plugin_version: "1.0.0".into(),
            api_version: "1".into(),
            manifest_sha256: "abc".into(),
            declared_operations: vec!["vendorx.do".into()],
            labels: HashMap::new(),
            seat_model: SeatModel::Tenant,
        }
    }

    #[test]
    fn complete_declaration_is_valid() {
        declaration().validate().expect("完整声明应当通过");
    }

    /// 空声明会让插件静默退化成 unverified —— 本库的存在意义就是不让它发生。
    #[test]
    fn empty_declared_operations_is_rejected_locally() {
        let mut d = declaration();
        d.declared_operations.clear();
        assert!(matches!(d.validate(), Err(HostError::Invalid(_))));
    }

    /// 注册的能力不在自己的声明里 —— capability 会拒,这里提前拦下。
    #[test]
    fn capability_id_must_be_declared() {
        let mut d = declaration();
        d.declared_operations = vec!["vendorx.other".into()];
        assert!(matches!(d.validate(), Err(HostError::Invalid(_))));
    }
}