1use chrono::Utc;
12use semver::{Version, VersionReq};
13use serde::{Deserialize, Serialize};
14use strum::{Display, IntoStaticStr};
15
16use crate::plugin::{
17 PluginDeregistrationOutcome, PluginError, deregister_plugin_registration_checked,
18};
19
20pub type DynamicPluginId = String;
22
23pub const DYNAMIC_PLUGIN_MANIFEST_FILENAME: &str = "relay-plugin.toml";
25
26mod host;
27mod manifest;
28mod native;
29mod registry;
30#[cfg(feature = "worker-grpc")]
31mod worker;
32
33pub use host::*;
34pub use manifest::*;
35pub use native::*;
36pub use registry::*;
37#[cfg(feature = "worker-grpc")]
38pub use worker::*;
39
40#[derive(Debug)]
41pub(crate) struct DynamicPluginTeardownOutcome {
42 pub(crate) errors: Vec<String>,
43 pub(crate) safe_to_unload: bool,
44}
45
46impl DynamicPluginTeardownOutcome {
47 pub(crate) fn success() -> Self {
48 Self {
49 errors: Vec::new(),
50 safe_to_unload: true,
51 }
52 }
53
54 pub(crate) fn record_error(&mut self, error: impl Into<String>, safe_to_unload: bool) {
55 self.errors.push(error.into());
56 self.safe_to_unload &= safe_to_unload;
57 }
58
59 pub(crate) fn merge(&mut self, other: Self) {
60 self.errors.extend(other.errors);
61 self.safe_to_unload &= other.safe_to_unload;
62 }
63}
64
65pub(super) fn deregister_tracked_registrations_checked(
66 registrations: &mut Vec<(String, u64)>,
67 plugin_type: &str,
68) -> DynamicPluginTeardownOutcome {
69 let mut outcome = DynamicPluginTeardownOutcome::success();
70 for (plugin_kind, registration_id) in std::mem::take(registrations).into_iter().rev() {
71 match deregister_plugin_registration_checked(&plugin_kind, registration_id) {
72 Ok(PluginDeregistrationOutcome::Removed) => {}
73 Ok(PluginDeregistrationOutcome::Missing) => outcome.record_error(
74 format!(
75 "{plugin_type} plugin kind '{plugin_kind}' was not registered during teardown"
76 ),
77 true,
78 ),
79 Ok(PluginDeregistrationOutcome::Replaced) => outcome.record_error(
80 format!(
81 "{plugin_type} plugin kind '{plugin_kind}' was replaced during teardown and was left registered"
82 ),
83 true,
84 ),
85 Err(error) => outcome.record_error(
86 format!(
87 "failed to deregister {plugin_type} plugin kind '{plugin_kind}': {error}"
88 ),
89 false,
90 ),
91 }
92 }
93 outcome
94}
95
96pub(super) fn validate_annotated_request_consumer_compatibility(
97 relay: &str,
98 plugin_kind: &str,
99) -> crate::plugin::Result<()> {
100 let requirement = VersionReq::parse(relay).map_err(|error| {
101 PluginError::InvalidConfig(format!("invalid compat.relay version requirement: {error}"))
102 })?;
103 if requirement.matches(&Version::new(0, 5, u64::MAX)) {
104 return Err(PluginError::InvalidConfig(format!(
105 "dynamic plugin '{plugin_kind}' registers an LLM request intercept and must declare compat.relay = \">=0.6,<1.0\" or another range that excludes Relay 0.5"
106 )));
107 }
108 Ok(())
109}
110
111#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Display)]
113#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
114#[serde(rename_all = "snake_case")]
115#[strum(serialize_all = "snake_case")]
116pub enum DynamicPluginKind {
117 RustDynamic,
119 Worker,
121}
122
123#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Display)]
125#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
126#[serde(rename_all = "snake_case")]
127#[strum(serialize_all = "snake_case")]
128pub enum WorkerRuntime {
129 Python,
131 Rust,
133 Command,
135}
136
137#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Display)]
139#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
140#[serde(rename_all = "snake_case")]
141#[strum(serialize_all = "snake_case")]
142pub enum DynamicPluginCapability {
143 PluginNative,
145 PluginWorker,
147 ConfigSchema,
149}
150
151#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Display)]
153#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
154#[serde(rename_all = "snake_case")]
155#[strum(serialize_all = "snake_case")]
156pub enum DynamicPluginStartupClass {
157 Optional,
159 Required,
161}
162
163#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Display)]
165#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
166#[serde(rename_all = "snake_case")]
167#[strum(serialize_all = "snake_case")]
168pub enum DynamicPluginAttestationMode {
169 IntegrityOnly,
171 SignatureIfPresent,
173 SignatureRequired,
175}
176
177#[derive(
179 Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, Hash, IntoStaticStr,
180)]
181#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
182#[serde(rename_all = "snake_case")]
183#[strum(serialize_all = "snake_case")]
184pub enum DynamicPluginCheckState {
185 #[default]
187 Unknown,
188 Valid,
190 Invalid,
192}
193
194#[derive(
196 Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, Hash, IntoStaticStr,
197)]
198#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
199#[serde(rename_all = "snake_case")]
200#[strum(serialize_all = "snake_case")]
201pub enum DynamicPluginRuntimeState {
202 #[default]
204 Stopped,
205 Starting,
207 Running,
209 Failed,
211}
212
213#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Display)]
215#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
216#[serde(rename_all = "snake_case")]
217#[strum(serialize_all = "snake_case")]
218pub enum DynamicPluginFailurePhase {
219 Validation,
221 Activation,
223 Runtime,
225 Policy,
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
231#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
232pub struct DynamicPluginMetadata {
233 pub id: DynamicPluginId,
235 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub name: Option<String>,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub version: Option<String>,
241 pub kind: DynamicPluginKind,
243 #[serde(default)]
245 pub generation: u64,
246 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub created_at: Option<String>,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub updated_at: Option<String>,
252}
253
254#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
256#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
257pub struct DynamicPluginSource {
258 #[serde(default, skip_serializing_if = "Option::is_none")]
260 pub manifest_ref: Option<String>,
261 #[serde(default, skip_serializing_if = "Option::is_none")]
263 pub artifact_ref: Option<String>,
264 #[serde(default, skip_serializing_if = "Option::is_none")]
266 pub environment_ref: Option<String>,
267 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub artifact_digest: Option<String>,
270}
271
272#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
274#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
275pub struct DynamicPluginSpec {
276 #[serde(default = "default_present")]
278 pub present: bool,
279 #[serde(default)]
281 pub enabled: bool,
282 #[serde(default, skip_serializing_if = "Option::is_none")]
284 pub config_ref: Option<String>,
285}
286
287pub(crate) fn default_present() -> bool {
288 true
289}
290
291impl Default for DynamicPluginSpec {
292 fn default() -> Self {
293 Self {
294 present: true,
295 enabled: false,
296 config_ref: None,
297 }
298 }
299}
300
301#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
303#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
304#[serde(rename_all = "snake_case")]
305pub enum DynamicPluginCompatibility {
306 RustDynamic(DynamicPluginRustCompatibility),
308 Worker(DynamicPluginWorkerCompatibility),
310}
311
312#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
314#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
315pub struct DynamicPluginWorkerCompatibility {
316 pub relay: String,
318 pub worker_protocol: String,
320}
321
322#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
324#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
325pub struct DynamicPluginRustCompatibility {
326 pub relay: String,
328 pub native_api: String,
330}
331
332#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
334#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
335#[serde(rename_all = "snake_case")]
336pub enum DynamicPluginLoadContract {
337 Worker(DynamicPluginWorkerLoadContract),
339 RustDynamic(DynamicPluginRustLoadContract),
341}
342
343#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
345#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
346pub struct DynamicPluginWorkerLoadContract {
347 pub runtime: WorkerRuntime,
349 pub entrypoint: String,
351}
352
353#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
355#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
356pub struct DynamicPluginRustLoadContract {
357 pub library: String,
359 pub symbol: String,
361}
362
363#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
365#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
366pub struct DynamicPluginFailure {
367 pub phase: DynamicPluginFailurePhase,
369 pub code: String,
371 pub message: String,
373}
374
375#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
377#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
378pub struct DynamicPluginValidationStatus {
379 #[serde(default)]
381 pub manifest: DynamicPluginCheckState,
382 #[serde(default)]
384 pub compatibility: DynamicPluginCheckState,
385 #[serde(default)]
387 pub integrity: DynamicPluginCheckState,
388 #[serde(default)]
390 pub environment: DynamicPluginCheckState,
391 #[serde(default)]
393 pub authenticity: DynamicPluginCheckState,
394 #[serde(default)]
396 pub policy_satisfied: DynamicPluginCheckState,
397 #[serde(default, skip_serializing_if = "Option::is_none")]
399 pub checked_at: Option<String>,
400 #[serde(default, skip_serializing_if = "Option::is_none")]
402 pub message: Option<String>,
403}
404
405impl Default for DynamicPluginValidationStatus {
406 fn default() -> Self {
407 Self {
408 manifest: DynamicPluginCheckState::Unknown,
409 compatibility: DynamicPluginCheckState::Unknown,
410 integrity: DynamicPluginCheckState::Unknown,
411 environment: DynamicPluginCheckState::Unknown,
412 authenticity: DynamicPluginCheckState::Unknown,
413 policy_satisfied: DynamicPluginCheckState::Unknown,
414 checked_at: None,
415 message: None,
416 }
417 }
418}
419
420#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
422#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
423pub struct DynamicPluginRuntimeStatus {
424 #[serde(default)]
426 pub state: DynamicPluginRuntimeState,
427 #[serde(default)]
429 pub observed_generation: u64,
430 #[serde(default, skip_serializing_if = "Option::is_none")]
432 pub started_at: Option<String>,
433 #[serde(default, skip_serializing_if = "Option::is_none")]
435 pub updated_at: Option<String>,
436 #[serde(default, skip_serializing_if = "Option::is_none")]
438 pub message: Option<String>,
439}
440
441impl Default for DynamicPluginRuntimeStatus {
442 fn default() -> Self {
443 Self {
444 state: DynamicPluginRuntimeState::Stopped,
445 observed_generation: 0,
446 started_at: None,
447 updated_at: None,
448 message: None,
449 }
450 }
451}
452
453#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
455#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
456pub struct DynamicPluginStatus {
457 #[serde(default)]
459 pub validation: DynamicPluginValidationStatus,
460 #[serde(default)]
462 pub runtime: DynamicPluginRuntimeStatus,
463 #[serde(default, skip_serializing_if = "Option::is_none")]
465 pub startup_class: Option<DynamicPluginStartupClass>,
466 #[serde(default, skip_serializing_if = "Option::is_none")]
468 pub attestation_mode: Option<DynamicPluginAttestationMode>,
469 #[serde(default, skip_serializing_if = "Option::is_none")]
471 pub last_error: Option<DynamicPluginFailure>,
472}
473
474#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
476#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
477pub struct DynamicPluginRecord {
478 pub metadata: DynamicPluginMetadata,
480 #[serde(default)]
482 pub source: DynamicPluginSource,
483 #[serde(default)]
485 pub spec: DynamicPluginSpec,
486 pub compatibility: DynamicPluginCompatibility,
488 pub load: DynamicPluginLoadContract,
490 #[serde(default)]
492 pub status: DynamicPluginStatus,
493}
494
495impl DynamicPluginRecord {
496 pub fn is_reconciled(&self) -> bool {
498 self.status.runtime.observed_generation == self.metadata.generation
499 }
500
501 pub fn is_tombstoned(&self) -> bool {
503 !self.spec.present
504 }
505}
506
507pub(crate) fn current_timestamp() -> String {
508 Utc::now().to_rfc3339()
509}
510
511pub(crate) fn stamp_creation_metadata(metadata: &mut DynamicPluginMetadata) {
512 if metadata.created_at.is_none() {
513 metadata.created_at = Some(current_timestamp());
514 }
515 if metadata.updated_at.is_none() {
516 metadata.updated_at = metadata.created_at.clone();
517 }
518}
519
520pub(crate) fn touch_metadata(metadata: &mut DynamicPluginMetadata) {
521 metadata.updated_at = Some(current_timestamp());
522}
523
524pub(crate) fn bump_generation(record: &mut DynamicPluginRecord) {
525 record.metadata.generation = record.metadata.generation.saturating_add(1);
526 touch_metadata(&mut record.metadata);
527}
528
529#[cfg(test)]
530#[path = "../../tests/unit/plugin_dynamic_tests.rs"]
531mod tests;