nemo_relay/plugin/dynamic/
host.rs1use std::collections::HashSet;
13use std::sync::{Arc, Mutex};
14
15use serde::{Deserialize, Serialize};
16use serde_json::{Map, Value as Json};
17
18use crate::plugin::{
19 ConfigReport, PluginComponentSpec, PluginConfig, PluginHostLease, Result,
20 acquire_plugin_host_lease, clear_plugin_configuration_for_host,
21 ensure_builtin_plugins_registered, initialize_plugins_exact_for_host, resolve_plugin_config,
22 run_owned_plugin_mutation,
23};
24
25use super::{
26 DynamicPluginKind, DynamicPluginTeardownOutcome, NativePluginActivation, NativePluginLoadSpec,
27 load_native_plugins,
28};
29
30#[cfg(feature = "worker-grpc")]
31use super::{WorkerPluginActivation, WorkerPluginLoadSpec, load_worker_plugins};
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
35#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
36pub struct DynamicPluginActivationSpec {
37 pub plugin_id: String,
39 pub kind: DynamicPluginKind,
41 pub manifest_ref: String,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub environment_ref: Option<String>,
46 #[serde(default)]
48 pub config: Map<String, Json>,
49}
50
51#[must_use = "dropping the activation clears and unloads its dynamic plugins"]
57pub struct PluginHostActivation {
58 active: bool,
59 native: Option<NativePluginActivation>,
60 #[cfg(feature = "worker-grpc")]
61 worker: Option<WorkerPluginActivation>,
62 claim: Option<PluginHostLease>,
63}
64
65impl PluginHostActivation {
66 pub async fn activate<I>(
74 config: PluginConfig,
75 dynamic_plugins: I,
76 ) -> Result<(Self, ConfigReport)>
77 where
78 I: IntoIterator<Item = DynamicPluginActivationSpec>,
79 {
80 let dynamic_plugins = dynamic_plugins.into_iter().collect::<Vec<_>>();
81 validate_dynamic_plugin_specs(&dynamic_plugins)?;
82 Self::activate_validated(config, dynamic_plugins, Vec::new()).await
83 }
84
85 pub async fn activate_with_discovered_config<I>(
92 config: PluginConfig,
93 dynamic_plugins: I,
94 ) -> Result<(Self, ConfigReport)>
95 where
96 I: IntoIterator<Item = DynamicPluginActivationSpec>,
97 {
98 let dynamic_plugins = dynamic_plugins.into_iter().collect::<Vec<_>>();
99 validate_dynamic_plugin_specs(&dynamic_plugins)?;
100 let resolved = resolve_plugin_config(config)?;
101 Self::activate_validated(resolved.config, dynamic_plugins, resolved.diagnostics).await
102 }
103
104 async fn activate_validated(
105 config: PluginConfig,
106 dynamic_plugins: Vec<DynamicPluginActivationSpec>,
107 diagnostics: Vec<crate::plugin::ConfigDiagnostic>,
108 ) -> Result<(Self, ConfigReport)> {
109 run_owned_plugin_mutation("dynamic plugin activation", move || async move {
110 Self::activate_inner(config, dynamic_plugins, diagnostics).await
111 })
112 .await
113 }
114
115 async fn activate_inner(
116 mut config: PluginConfig,
117 dynamic_plugins: Vec<DynamicPluginActivationSpec>,
118 diagnostics: Vec<crate::plugin::ConfigDiagnostic>,
119 ) -> Result<(Self, ConfigReport)> {
120 let dynamic_plugin_count = dynamic_plugins.len();
121 log::info!(
122 target: "nemo_relay.plugin",
123 event = "dynamic_plugin_activation_started",
124 plugin_count = dynamic_plugin_count;
125 "Dynamic plugin activation started"
126 );
127 let claim = acquire_plugin_host_lease()?;
128
129 #[cfg(not(feature = "worker-grpc"))]
130 if let Some(plugin) = dynamic_plugins
131 .iter()
132 .find(|plugin| plugin.kind == DynamicPluginKind::Worker)
133 {
134 return Err(crate::plugin::PluginError::InvalidConfig(format!(
135 "worker dynamic plugin '{}' requires the 'worker-grpc' feature",
136 plugin.plugin_id
137 )));
138 }
139
140 ensure_builtin_plugins_registered()?;
144
145 let native_specs = dynamic_plugins
146 .iter()
147 .filter(|plugin| plugin.kind == DynamicPluginKind::RustDynamic)
148 .map(|plugin| NativePluginLoadSpec {
149 plugin_id: plugin.plugin_id.clone(),
150 manifest_ref: plugin.manifest_ref.clone(),
151 })
152 .collect::<Vec<_>>();
153 let native = (!native_specs.is_empty())
154 .then(|| {
155 load_native_plugins(native_specs)
156 .map_err(|error| plugin_error_context("native plugin load failed", error))
157 })
158 .transpose()?;
159
160 #[cfg(feature = "worker-grpc")]
161 let worker = {
162 let worker_specs = dynamic_plugins
163 .iter()
164 .filter(|plugin| plugin.kind == DynamicPluginKind::Worker)
165 .map(|plugin| WorkerPluginLoadSpec {
166 plugin_id: plugin.plugin_id.clone(),
167 manifest_ref: plugin.manifest_ref.clone(),
168 environment_ref: plugin.environment_ref.clone(),
169 config: plugin.config.clone(),
170 })
171 .collect::<Vec<_>>();
172 (!worker_specs.is_empty())
173 .then(|| {
174 load_worker_plugins(worker_specs)
175 .map_err(|error| plugin_error_context("worker plugin load failed", error))
176 })
177 .transpose()?
178 };
179
180 config.components.extend(
181 dynamic_plugins
182 .into_iter()
183 .map(|plugin| PluginComponentSpec {
184 kind: plugin.plugin_id,
185 enabled: true,
186 config: plugin.config,
187 }),
188 );
189 let rollback_failures = Arc::new(Mutex::new(Vec::new()));
190 let owner_id = claim.owner_id();
191 let initialization = tokio::spawn(initialize_plugins_exact_for_host(
192 config,
193 owner_id,
194 Arc::clone(&rollback_failures),
195 diagnostics,
196 ))
197 .await
198 .map_err(|error| {
199 crate::plugin::PluginError::Internal(format!(
200 "dynamic plugin initialization task failed: {error}"
201 ))
202 });
203 let report = match initialization.and_then(|result| result) {
204 Ok(report) => report,
205 Err(error) => {
206 let failures = rollback_failures
207 .lock()
208 .map(|failures| failures.clone())
209 .unwrap_or_else(|lock_error| {
210 vec![format!("rollback failure lock poisoned: {lock_error}")]
211 });
212 if failures.is_empty() {
213 return Err(error);
214 }
215 log::error!(
216 target: "nemo_relay.plugin",
217 event = "plugin_rollback_failed",
218 plugin_count = dynamic_plugin_count,
219 failure_count = failures.len();
220 "Dynamic plugin activation rollback was incomplete"
221 );
222 if let Some(native) = native {
223 std::mem::forget(native);
224 }
225 #[cfg(feature = "worker-grpc")]
226 if let Some(worker) = worker {
227 std::mem::forget(worker);
228 }
229 std::mem::forget(claim);
230 return Err(crate::plugin::PluginError::RegistrationFailed(format!(
231 concat!(
232 "{}; activation rollback was incomplete: {}; the loaded runtimes ",
233 "were retained because callbacks may remain registered"
234 ),
235 error,
236 failures.join("; ")
237 )));
238 }
239 };
240
241 log::info!(
242 target: "nemo_relay.plugin",
243 event = "dynamic_plugin_activated",
244 plugin_count = dynamic_plugin_count;
245 "Dynamic plugins activated"
246 );
247 Ok((
248 Self {
249 active: true,
250 native,
251 #[cfg(feature = "worker-grpc")]
252 worker,
253 claim: Some(claim),
254 },
255 report,
256 ))
257 }
258
259 pub fn is_active(&self) -> bool {
265 self.active
266 }
267
268 pub fn clear(mut self) -> Result<()> {
270 self.clear_inner()
271 }
272
273 fn clear_inner(&mut self) -> Result<()> {
274 if !self.active {
275 return Ok(());
276 }
277 self.active = false;
278 let outcome = self
279 .claim
280 .as_ref()
281 .map(|claim| clear_plugin_configuration_for_host(claim.owner_id()))
282 .unwrap_or(crate::plugin::PluginHostClearOutcome {
283 result: Ok(()),
284 callbacks_cleared: true,
285 });
286 let mut errors = outcome
287 .result
288 .err()
289 .map(|error| vec![error.to_string()])
290 .unwrap_or_default();
291 if !outcome.callbacks_cleared {
292 self.retain_loaded_runtimes();
296 return Err(retained_runtime_error(errors));
297 }
298
299 let mut runtime_outcome = DynamicPluginTeardownOutcome::success();
300 if let Some(native) = &mut self.native {
301 runtime_outcome.merge(native.deregister_plugin_kinds_checked());
302 }
303 #[cfg(feature = "worker-grpc")]
304 if let Some(worker) = &mut self.worker {
305 runtime_outcome.merge(worker.deregister_plugin_kinds_checked());
306 }
307
308 #[cfg(feature = "worker-grpc")]
312 if runtime_outcome.safe_to_unload
313 && let Some(worker) = &self.worker
314 {
315 runtime_outcome.merge(worker.shutdown_plugins_checked());
316 }
317 errors.extend(runtime_outcome.errors);
318
319 if !runtime_outcome.safe_to_unload {
320 self.retain_loaded_runtimes();
321 return Err(retained_runtime_error(errors));
322 }
323
324 self.native.take();
328 #[cfg(feature = "worker-grpc")]
329 self.worker.take();
330 self.claim.take();
331
332 if errors.is_empty() {
333 log::info!(
334 target: "nemo_relay.plugin",
335 event = "dynamic_plugin_cleared";
336 "Dynamic plugin activation cleared"
337 );
338 Ok(())
339 } else {
340 Err(crate::plugin::PluginError::RegistrationFailed(format!(
341 "dynamic plugin teardown failed: {}",
342 errors.join("; ")
343 )))
344 }
345 }
346
347 fn retain_loaded_runtimes(&mut self) {
348 if let Some(native) = self.native.take() {
349 std::mem::forget(native);
350 }
351 #[cfg(feature = "worker-grpc")]
352 if let Some(worker) = self.worker.take() {
353 std::mem::forget(worker);
354 }
355 if let Some(claim) = self.claim.take() {
356 std::mem::forget(claim);
357 }
358 }
359}
360
361fn validate_dynamic_plugin_specs(dynamic_plugins: &[DynamicPluginActivationSpec]) -> Result<()> {
362 if dynamic_plugins.is_empty() {
363 return Err(crate::plugin::PluginError::InvalidConfig(
364 concat!(
365 "dynamic plugin activation requires at least one dynamic plugin; ",
366 "use plugin initialization for a static-only configuration"
367 )
368 .into(),
369 ));
370 }
371 let mut plugin_ids = HashSet::with_capacity(dynamic_plugins.len());
372 for plugin in dynamic_plugins {
373 if !plugin_ids.insert(plugin.plugin_id.as_str()) {
374 return Err(crate::plugin::PluginError::InvalidConfig(format!(
375 "duplicate dynamic plugin id '{}'",
376 plugin.plugin_id
377 )));
378 }
379 }
380 Ok(())
381}
382
383fn retained_runtime_error(errors: Vec<String>) -> crate::plugin::PluginError {
384 crate::plugin::PluginError::RegistrationFailed(format!(
385 concat!(
386 "{}; the loaded runtimes and activation owner were retained because safe ",
387 "unloading could not be proven"
388 ),
389 if errors.is_empty() {
390 "dynamic plugin teardown was incomplete".into()
391 } else {
392 errors.join("; ")
393 }
394 ))
395}
396
397fn plugin_error_context(
398 prefix: &str,
399 error: crate::plugin::PluginError,
400) -> crate::plugin::PluginError {
401 use crate::plugin::PluginError;
402
403 match error {
404 PluginError::InvalidConfig(message) => {
405 PluginError::InvalidConfig(format!("{prefix}: {message}"))
406 }
407 PluginError::Conflict(message) => PluginError::Conflict(format!("{prefix}: {message}")),
408 PluginError::NotFound(message) => PluginError::NotFound(format!("{prefix}: {message}")),
409 PluginError::Serialization(error) => {
410 PluginError::Internal(format!("{prefix}: serialization error: {error}"))
411 }
412 PluginError::Internal(message) => PluginError::Internal(format!("{prefix}: {message}")),
413 PluginError::RegistrationFailed(message) => {
414 PluginError::RegistrationFailed(format!("{prefix}: {message}"))
415 }
416 }
417}
418
419impl Drop for PluginHostActivation {
420 fn drop(&mut self) {
421 if self.clear_inner().is_err() {
422 log::error!(
423 target: "nemo_relay.plugin",
424 event = "plugin_cleanup_failed",
425 cleanup = "dynamic_activation_drop";
426 "Dynamic plugin activation cleanup failed during drop"
427 );
428 }
429 }
430}
431
432#[cfg(test)]
433#[path = "../../../tests/unit/plugin_dynamic_host_tests.rs"]
434mod tests;