Skip to main content

flare_plugin_host/
lib.rs

1//! Flare 能力插件的**宿主接入库**:把插件向 capability 声明自己这件事做成三行。
2//!
3//! # 它解决什么
4//!
5//! 插件接入 capability 有两条路:
6//!
7//! 1. **被发现**:插件注册到 Consul/etcd,capability 按配置里的
8//!    `plugin_discovery_endpoints` 找到它。这条路走得通,但发现来的端点
9//!    **没有清单信息** —— capability 只能把它标成 `unverified`,
10//!    于是「声明即边界」对它无从强制。
11//! 2. **主动声明**(本库):插件启动后调用 `RegisterPluginEndpoint`,
12//!    带上版本、清单摘要与自己声明能处理的 operation 全集。
13//!    这样它才是 verified 的,声明之外的调用会被 capability 拒绝。
14//!
15//! 两条路不冲突:服务目录负责「进程在哪」,声明负责「它能做什么」。
16//! 本库只做后者 —— 前者各插件已经在用 `flare_server_core::discovery`,
17//! 没必要再包一层。
18//!
19//! # 为什么要有这个库,而不是每个插件自己调
20//!
21//! 「自己调」的问题不在于代码量,在于**容易少调**:漏了 declared_operations
22//! 就退化成 unverified,而这不会报错、只会让边界强制失效。把它做成一个
23//! 必须填满字段的结构体,漏填在编译期就暴露。
24//!
25//! ```no_run
26//! use flare_plugin_host::{PluginDeclaration, PluginHost};
27//!
28//! # async fn demo() -> Result<(), flare_plugin_host::HostError> {
29//! let declaration = PluginDeclaration {
30//!     tenant_id: "0".into(),
31//!     plugin_id: "flare-moments".into(),
32//!     capability_id: "social.moments.feed".into(),
33//!     grpc_authority: "127.0.0.1:50204".into(),
34//!     plugin_version: "1.0.0".into(),
35//!     api_version: "1".into(),
36//!     manifest_sha256: "….".into(),
37//!     declared_operations: vec!["social.moments.feed".into()],
38//!     labels: Default::default(),
39//!     seat_model: flare_plugin_host::SeatModel::Tenant,
40//! };
41//! PluginHost::connect("http://127.0.0.1:50051")
42//!     .await?
43//!     .announce(&declaration)
44//!     .await?;
45//! # Ok(())
46//! # }
47//! ```
48
49/// 完整文档。
50///
51/// 放在 crate 里而不是仓库里:外部读者拿到的是 crates.io 上的包与
52/// [docs.rs](https://docs.rs/flare-plugin-host),包外的目录他们看不到。
53pub mod docs {
54    /// 平台与插件之间的线上契约:一条 RPC 的每个字段是什么意思。
55    #[doc = include_str!("../docs/plugin-contract.md")]
56    pub mod plugin_contract {}
57
58    /// 从零做一个插件的完整流程,含常见错误的症状→原因对照表。
59    #[doc = include_str!("../docs/build-your-own-plugin.md")]
60    pub mod build_your_own_plugin {}
61
62    /// 可运行示例 `echo_plugin` 的说明(`cargo run --example echo_plugin`)。
63    #[doc = include_str!("../docs/echo-plugin.md")]
64    pub mod echo_plugin {}
65}
66
67use std::collections::HashMap;
68use std::time::Duration;
69
70use flare_grpc_proto::capability::capability_service_client::CapabilityServiceClient;
71use flare_grpc_proto::capability::{
72    DeregisterPluginEndpointRequest, RegisterPluginEndpointRequest,
73};
74use tonic::transport::Channel;
75
76#[derive(Debug, thiserror::Error)]
77pub enum HostError {
78    #[error("连接 capability 失败:{0}")]
79    Connect(String),
80    #[error("capability 拒绝了注册:{0}")]
81    Rejected(String),
82    #[error("gRPC 调用失败:{0}")]
83    Rpc(String),
84    #[error("声明不完整:{0}")]
85    Invalid(&'static str),
86}
87
88/// 插件对 capability 的完整声明。
89///
90/// 字段全部必填是刻意的:这些正是注册契约 v2 里「可选、缺失即降级为
91/// unverified」的那些。协议层必须可选(否则核心升级会打死所有旧插件),
92/// 但**新写的插件没有理由不填** —— 在这里做成必填,漏填就编译不过。
93#[derive(Debug, Clone)]
94pub struct PluginDeclaration {
95    pub tenant_id: String,
96    pub plugin_id: String,
97    /// 本次注册的能力 id。必须出现在 `declared_operations` 里,
98    /// 否则 capability 会当场拒绝(清单与注册对不上)。
99    ///
100    /// 它**不限制**插件承接的范围 —— 范围由 `declared_operations` 决定。
101    /// 这里填一个最好认的入口即可,它主要出现在路由簿与日志里。
102    pub capability_id: String,
103    /// 本插件的 gRPC 地址,capability 按它回调。
104    pub grpc_authority: String,
105    pub plugin_version: String,
106    pub api_version: String,
107    /// 插件清单(plugin.json)的 sha256,用于确认部署物与目录一致。
108    pub manifest_sha256: String,
109    /// 本插件能处理的 operation 全集。**留空即退化为 unverified**。
110    pub declared_operations: Vec<String>,
111    /// 附加标签,例如 `health_protocol` 声明特殊探活协议。
112    pub labels: HashMap<String, String>,
113    /// 计费/授权单位:`SeatModel::Tenant`(装了全员可用)或
114    /// `SeatModel::PerUser`(还需逐人授权)。
115    ///
116    /// 这是**产品决策**,平台不替插件决定它怎么卖,所以必填。
117    pub seat_model: SeatModel,
118}
119
120/// 计费/授权单位。
121///
122/// - `Tenant`:租户装了就全员可用。绝大多数插件属于这一类 ——
123///   组织装了大家就用,不该再逐人发放。
124/// - `PerUser`:还需逐人授权。留给两类:有边际成本的(AI 按 token 烧钱,
125///   按席位卖才不亏)与需合规隔离的(DLP 导出只给特定角色)。
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub enum SeatModel {
128    Tenant,
129    PerUser,
130}
131
132impl SeatModel {
133    fn as_wire(self) -> &'static str {
134        match self {
135            Self::Tenant => "tenant",
136            Self::PerUser => "per_user",
137        }
138    }
139}
140
141impl PluginDeclaration {
142    /// 本地自检:把「注册后才发现对不上」提前到调用前。
143    ///
144    /// capability 侧也会校验(那是权威判定),这里重复一遍是为了让插件作者
145    /// 在自己的进程里就看到错误,而不是去翻 capability 的日志。
146    pub fn validate(&self) -> Result<(), HostError> {
147        if self.plugin_id.trim().is_empty() {
148            return Err(HostError::Invalid("plugin_id 不能为空"));
149        }
150        if self.capability_id.trim().is_empty() {
151            return Err(HostError::Invalid("capability_id 不能为空"));
152        }
153        if self.grpc_authority.trim().is_empty() {
154            return Err(HostError::Invalid("grpc_authority 不能为空"));
155        }
156        if self.declared_operations.is_empty() {
157            return Err(HostError::Invalid(
158                "declared_operations 为空会让插件退化为 unverified,声明边界无法强制",
159            ));
160        }
161        if !self.declared_operations.contains(&self.capability_id) {
162            return Err(HostError::Invalid(
163                "capability_id 必须出现在 declared_operations 里",
164            ));
165        }
166        Ok(())
167    }
168}
169
170/// 与 capability 控制面的连接。
171pub struct PluginHost {
172    client: CapabilityServiceClient<Channel>,
173}
174
175impl PluginHost {
176    /// 连接 capability。`endpoint` 形如 `http://host:port`。
177    pub async fn connect(endpoint: impl Into<String>) -> Result<Self, HostError> {
178        let endpoint = endpoint.into();
179        let channel = Channel::from_shared(endpoint.clone())
180            .map_err(|e| HostError::Connect(format!("{endpoint}: {e}")))?
181            .connect_timeout(Duration::from_secs(5))
182            .connect()
183            .await
184            .map_err(|e| HostError::Connect(format!("{endpoint}: {e}")))?;
185        Ok(Self {
186            client: CapabilityServiceClient::new(channel),
187        })
188    }
189
190    /// 向 capability 声明本插件。
191    ///
192    /// 成功后该实例是 **verified** 的:声明之外的调用会被 capability 拒绝。
193    pub async fn announce(&mut self, declaration: &PluginDeclaration) -> Result<(), HostError> {
194        declaration.validate()?;
195
196        let response = self
197            .client
198            .register_plugin_endpoint(RegisterPluginEndpointRequest {
199                tenant_id: declaration.tenant_id.clone(),
200                plugin_id: declaration.plugin_id.clone(),
201                capability_id: declaration.capability_id.clone(),
202                grpc_authority: declaration.grpc_authority.clone(),
203                labels: declaration.labels.clone(),
204                request_id: String::new(),
205                plugin_version: declaration.plugin_version.clone(),
206                api_version: declaration.api_version.clone(),
207                manifest_sha256: declaration.manifest_sha256.clone(),
208                declared_operations: declaration.declared_operations.clone(),
209                seat_model: declaration.seat_model.as_wire().to_string(),
210            })
211            .await
212            .map_err(|e| HostError::Rpc(e.to_string()))?
213            .into_inner();
214
215        if !response.accepted {
216            return Err(HostError::Rejected(response.message));
217        }
218        tracing::info!(
219            plugin_id = %declaration.plugin_id,
220            capability_id = %declaration.capability_id,
221            operations = declaration.declared_operations.len(),
222            "plugin announced to capability"
223        );
224        Ok(())
225    }
226
227    /// 优雅摘除:停机前告诉 capability 别再往这里派活。
228    ///
229    /// 不调用也不会坏 —— capability 的健康检查最终会把它摘掉。但那要等一个
230    /// 检查周期,期间的调用会打到正在退出的进程上。
231    /// 注销粒度是 `(tenant, plugin)`,与注册粒度一致 —— 一个插件进程在路由簿里
232    /// 只有一条记录,承接范围由 `declared_operations` 表达,所以一次注销即摘干净。
233    pub async fn withdraw(&mut self, tenant_id: &str, plugin_id: &str) -> Result<(), HostError> {
234        self.client
235            .deregister_plugin_endpoint(DeregisterPluginEndpointRequest {
236                tenant_id: tenant_id.to_string(),
237                plugin_id: plugin_id.to_string(),
238                request_id: String::new(),
239            })
240            .await
241            .map_err(|e| HostError::Rpc(e.to_string()))?;
242        tracing::info!(plugin_id, "plugin withdrawn from capability");
243        Ok(())
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    fn declaration() -> PluginDeclaration {
252        PluginDeclaration {
253            tenant_id: "0".into(),
254            plugin_id: "p1".into(),
255            capability_id: "vendorx.do".into(),
256            grpc_authority: "127.0.0.1:1".into(),
257            plugin_version: "1.0.0".into(),
258            api_version: "1".into(),
259            manifest_sha256: "abc".into(),
260            declared_operations: vec!["vendorx.do".into()],
261            labels: HashMap::new(),
262            seat_model: SeatModel::Tenant,
263        }
264    }
265
266    #[test]
267    fn complete_declaration_is_valid() {
268        declaration().validate().expect("完整声明应当通过");
269    }
270
271    /// 空声明会让插件静默退化成 unverified —— 本库的存在意义就是不让它发生。
272    #[test]
273    fn empty_declared_operations_is_rejected_locally() {
274        let mut d = declaration();
275        d.declared_operations.clear();
276        assert!(matches!(d.validate(), Err(HostError::Invalid(_))));
277    }
278
279    /// 注册的能力不在自己的声明里 —— capability 会拒,这里提前拦下。
280    #[test]
281    fn capability_id_must_be_declared() {
282        let mut d = declaration();
283        d.declared_operations = vec!["vendorx.other".into()];
284        assert!(matches!(d.validate(), Err(HostError::Invalid(_))));
285    }
286}