use chrono::Utc;
use semver::{Version, VersionReq};
use serde::{Deserialize, Serialize};
use strum::{Display, IntoStaticStr};
use crate::plugin::{
PluginDeregistrationOutcome, PluginError, deregister_plugin_registration_checked,
};
pub type DynamicPluginId = String;
pub const DYNAMIC_PLUGIN_MANIFEST_FILENAME: &str = "relay-plugin.toml";
mod host;
mod manifest;
mod native;
mod registry;
#[cfg(feature = "worker-grpc")]
mod worker;
pub use host::*;
pub use manifest::*;
pub use native::*;
pub use registry::*;
#[cfg(feature = "worker-grpc")]
pub use worker::*;
#[derive(Debug)]
pub(crate) struct DynamicPluginTeardownOutcome {
pub(crate) errors: Vec<String>,
pub(crate) safe_to_unload: bool,
}
impl DynamicPluginTeardownOutcome {
pub(crate) fn success() -> Self {
Self {
errors: Vec::new(),
safe_to_unload: true,
}
}
pub(crate) fn record_error(&mut self, error: impl Into<String>, safe_to_unload: bool) {
self.errors.push(error.into());
self.safe_to_unload &= safe_to_unload;
}
pub(crate) fn merge(&mut self, other: Self) {
self.errors.extend(other.errors);
self.safe_to_unload &= other.safe_to_unload;
}
}
pub(super) fn deregister_tracked_registrations_checked(
registrations: &mut Vec<(String, u64)>,
plugin_type: &str,
) -> DynamicPluginTeardownOutcome {
let mut outcome = DynamicPluginTeardownOutcome::success();
for (plugin_kind, registration_id) in std::mem::take(registrations).into_iter().rev() {
match deregister_plugin_registration_checked(&plugin_kind, registration_id) {
Ok(PluginDeregistrationOutcome::Removed) => {}
Ok(PluginDeregistrationOutcome::Missing) => outcome.record_error(
format!(
"{plugin_type} plugin kind '{plugin_kind}' was not registered during teardown"
),
true,
),
Ok(PluginDeregistrationOutcome::Replaced) => outcome.record_error(
format!(
"{plugin_type} plugin kind '{plugin_kind}' was replaced during teardown and was left registered"
),
true,
),
Err(error) => outcome.record_error(
format!(
"failed to deregister {plugin_type} plugin kind '{plugin_kind}': {error}"
),
false,
),
}
}
outcome
}
pub(super) fn validate_annotated_request_consumer_compatibility(
relay: &str,
plugin_kind: &str,
) -> crate::plugin::Result<()> {
let requirement = VersionReq::parse(relay).map_err(|error| {
PluginError::InvalidConfig(format!("invalid compat.relay version requirement: {error}"))
})?;
if requirement.matches(&Version::new(0, 5, u64::MAX)) {
return Err(PluginError::InvalidConfig(format!(
"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"
)));
}
Ok(())
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Display)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum DynamicPluginKind {
RustDynamic,
Worker,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Display)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum WorkerRuntime {
Python,
Rust,
Command,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Display)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum DynamicPluginCapability {
PluginNative,
PluginWorker,
ConfigSchema,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Display)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum DynamicPluginStartupClass {
Optional,
Required,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Display)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum DynamicPluginAttestationMode {
IntegrityOnly,
SignatureIfPresent,
SignatureRequired,
}
#[derive(
Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, Hash, IntoStaticStr,
)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum DynamicPluginCheckState {
#[default]
Unknown,
Valid,
Invalid,
}
#[derive(
Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, Hash, IntoStaticStr,
)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum DynamicPluginRuntimeState {
#[default]
Stopped,
Starting,
Running,
Failed,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Display)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum DynamicPluginFailurePhase {
Validation,
Activation,
Runtime,
Policy,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct DynamicPluginMetadata {
pub id: DynamicPluginId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
pub kind: DynamicPluginKind,
#[serde(default)]
pub generation: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updated_at: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct DynamicPluginSource {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub manifest_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artifact_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub environment_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artifact_digest: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct DynamicPluginSpec {
#[serde(default = "default_present")]
pub present: bool,
#[serde(default)]
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub config_ref: Option<String>,
}
pub(crate) fn default_present() -> bool {
true
}
impl Default for DynamicPluginSpec {
fn default() -> Self {
Self {
present: true,
enabled: false,
config_ref: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum DynamicPluginCompatibility {
RustDynamic(DynamicPluginRustCompatibility),
Worker(DynamicPluginWorkerCompatibility),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct DynamicPluginWorkerCompatibility {
pub relay: String,
pub worker_protocol: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct DynamicPluginRustCompatibility {
pub relay: String,
pub native_api: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum DynamicPluginLoadContract {
Worker(DynamicPluginWorkerLoadContract),
RustDynamic(DynamicPluginRustLoadContract),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct DynamicPluginWorkerLoadContract {
pub runtime: WorkerRuntime,
pub entrypoint: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct DynamicPluginRustLoadContract {
pub library: String,
pub symbol: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct DynamicPluginFailure {
pub phase: DynamicPluginFailurePhase,
pub code: String,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct DynamicPluginValidationStatus {
#[serde(default)]
pub manifest: DynamicPluginCheckState,
#[serde(default)]
pub compatibility: DynamicPluginCheckState,
#[serde(default)]
pub integrity: DynamicPluginCheckState,
#[serde(default)]
pub environment: DynamicPluginCheckState,
#[serde(default)]
pub authenticity: DynamicPluginCheckState,
#[serde(default)]
pub policy_satisfied: DynamicPluginCheckState,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub checked_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
impl Default for DynamicPluginValidationStatus {
fn default() -> Self {
Self {
manifest: DynamicPluginCheckState::Unknown,
compatibility: DynamicPluginCheckState::Unknown,
integrity: DynamicPluginCheckState::Unknown,
environment: DynamicPluginCheckState::Unknown,
authenticity: DynamicPluginCheckState::Unknown,
policy_satisfied: DynamicPluginCheckState::Unknown,
checked_at: None,
message: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct DynamicPluginRuntimeStatus {
#[serde(default)]
pub state: DynamicPluginRuntimeState,
#[serde(default)]
pub observed_generation: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub started_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updated_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
impl Default for DynamicPluginRuntimeStatus {
fn default() -> Self {
Self {
state: DynamicPluginRuntimeState::Stopped,
observed_generation: 0,
started_at: None,
updated_at: None,
message: None,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct DynamicPluginStatus {
#[serde(default)]
pub validation: DynamicPluginValidationStatus,
#[serde(default)]
pub runtime: DynamicPluginRuntimeStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub startup_class: Option<DynamicPluginStartupClass>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attestation_mode: Option<DynamicPluginAttestationMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_error: Option<DynamicPluginFailure>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct DynamicPluginRecord {
pub metadata: DynamicPluginMetadata,
#[serde(default)]
pub source: DynamicPluginSource,
#[serde(default)]
pub spec: DynamicPluginSpec,
pub compatibility: DynamicPluginCompatibility,
pub load: DynamicPluginLoadContract,
#[serde(default)]
pub status: DynamicPluginStatus,
}
impl DynamicPluginRecord {
pub fn is_reconciled(&self) -> bool {
self.status.runtime.observed_generation == self.metadata.generation
}
pub fn is_tombstoned(&self) -> bool {
!self.spec.present
}
}
pub(crate) fn current_timestamp() -> String {
Utc::now().to_rfc3339()
}
pub(crate) fn stamp_creation_metadata(metadata: &mut DynamicPluginMetadata) {
if metadata.created_at.is_none() {
metadata.created_at = Some(current_timestamp());
}
if metadata.updated_at.is_none() {
metadata.updated_at = metadata.created_at.clone();
}
}
pub(crate) fn touch_metadata(metadata: &mut DynamicPluginMetadata) {
metadata.updated_at = Some(current_timestamp());
}
pub(crate) fn bump_generation(record: &mut DynamicPluginRecord) {
record.metadata.generation = record.metadata.generation.saturating_add(1);
touch_metadata(&mut record.metadata);
}
#[cfg(test)]
#[path = "../../tests/unit/plugin_dynamic_tests.rs"]
mod tests;