Skip to main content

drasi_plugin_sdk/
lib.rs

1#![allow(unexpected_cfgs)]
2// Copyright 2025 The Drasi Authors.
3//
4// Licensed under the Apache License, Version 2.0 (the "License");
5// you may not use this file except in compliance with the License.
6// You may obtain a copy of the License at
7//
8//     http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13// See the License for the specific language governing permissions and
14// limitations under the License.
15
16//! # Drasi Plugin SDK
17//!
18//! The Drasi Plugin SDK provides the traits, types, and utilities needed to build
19//! plugins for the Drasi Server. Plugins can be compiled directly into the server
20//! binary (static linking) or built as shared libraries for dynamic loading.
21//!
22//! ## Quick Start
23//!
24//! ```rust,ignore
25//! use drasi_plugin_sdk::prelude::*;
26//!
27//! // 1. Define your configuration DTO with OpenAPI schema support
28//! #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
29//! #[serde(rename_all = "camelCase")]
30//! pub struct MySourceConfigDto {
31//!     /// The hostname to connect to
32//!     #[schema(value_type = ConfigValueString)]
33//!     pub host: ConfigValue<String>,
34//!
35//!     /// The port number
36//!     #[schema(value_type = ConfigValueU16)]
37//!     pub port: ConfigValue<u16>,
38//!
39//!     /// Optional timeout in milliseconds
40//!     #[serde(skip_serializing_if = "Option::is_none")]
41//!     #[schema(value_type = Option<ConfigValueU32>)]
42//!     pub timeout_ms: Option<ConfigValue<u32>>,
43//! }
44//!
45//! // 2. Implement the appropriate descriptor trait
46//! pub struct MySourceDescriptor;
47//!
48//! #[async_trait]
49//! impl SourcePluginDescriptor for MySourceDescriptor {
50//!     fn kind(&self) -> &str { "my-source" }
51//!     fn config_version(&self) -> &str { "1.0.0" }
52//!
53//!     fn config_schema_json(&self) -> String {
54//!         let schema = <MySourceConfigDto as utoipa::ToSchema>::schema();
55//!         serde_json::to_string(&schema).unwrap()
56//!     }
57//!
58//!     async fn create_source(
59//!         &self,
60//!         id: &str,
61//!         config_json: &serde_json::Value,
62//!         auto_start: bool,
63//!     ) -> anyhow::Result<Box<dyn drasi_lib::sources::Source>> {
64//!         let dto: MySourceConfigDto = serde_json::from_value(config_json.clone())?;
65//!         let mapper = DtoMapper::new();
66//!         let host = mapper.resolve_string(&dto.host)?;
67//!         let port = mapper.resolve_typed(&dto.port)?;
68//!         // Build and return your source implementation...
69//!         todo!()
70//!     }
71//! }
72//!
73//! // 3. Create a plugin registration
74//! pub fn register() -> PluginRegistration {
75//!     PluginRegistration::new()
76//!         .with_source(Box::new(MySourceDescriptor))
77//! }
78//! ```
79//!
80//! ## Static vs. Dynamic Plugins
81//!
82//! Plugins can be integrated with Drasi Server in two ways:
83//!
84//! ### Static Linking
85//!
86//! Compile the plugin directly into the server binary. Create a
87//! [`PluginRegistration`](registration::PluginRegistration) and pass its descriptors
88//! to the server's plugin registry at startup. This is the simplest approach and
89//! is shown in the Quick Start above.
90//!
91//! ### Dynamic Loading
92//!
93//! Build the plugin as a shared library (`cdylib`) that the server loads at runtime
94//! from a plugins directory. This allows deploying new plugins without recompiling
95//! the server. See [Creating a Dynamic Plugin](#creating-a-dynamic-plugin) below
96//! for the full workflow.
97//!
98//! ## Creating a Dynamic Plugin
99//!
100//! Dynamic plugins are compiled as shared libraries (`.so` on Linux, `.dylib` on
101//! macOS, `.dll` on Windows) and placed in the server's plugins directory. The server
102//! discovers and loads them automatically at startup.
103//!
104//! ### Step 1: Set up the crate
105//!
106//! In your plugin's `Cargo.toml`, set the crate type to `cdylib`:
107//!
108//! ```toml
109//! [lib]
110//! crate-type = ["cdylib"]
111//!
112//! [dependencies]
113//! drasi-plugin-sdk = "..."  # Must match the server's version exactly
114//! drasi-lib = "..."
115//! ```
116//!
117//! ### Step 2: Implement descriptor(s)
118//!
119//! Implement [`SourcePluginDescriptor`](descriptor::SourcePluginDescriptor),
120//! [`ReactionPluginDescriptor`](descriptor::ReactionPluginDescriptor), and/or
121//! [`BootstrapPluginDescriptor`](descriptor::BootstrapPluginDescriptor) for your
122//! plugin. See the [`descriptor`] module docs for the full trait requirements.
123//!
124//! ### Step 3: Export the entry point
125//!
126//! Every dynamic plugin shared library **must** export a C function named
127//! `drasi_plugin_init` that returns a heap-allocated
128//! [`PluginRegistration`](registration::PluginRegistration) via raw pointer:
129//!
130//! ```rust,ignore
131//! use drasi_plugin_sdk::prelude::*;
132//!
133//! #[no_mangle]
134//! pub extern "C" fn drasi_plugin_init() -> *mut PluginRegistration {
135//!     let registration = PluginRegistration::new()
136//!         .with_source(Box::new(MySourceDescriptor))
137//!         .with_reaction(Box::new(MyReactionDescriptor));
138//!     Box::into_raw(Box::new(registration))
139//! }
140//! ```
141//!
142//! **Important details:**
143//!
144//! - The function must be `#[no_mangle]` and `extern "C"` so the server can find it
145//!   via the C ABI.
146//! - The `PluginRegistration` must be heap-allocated with `Box::new` and returned as
147//!   a raw pointer via [`Box::into_raw`]. The server takes ownership by calling
148//!   `Box::from_raw`.
149//! - The [`PluginRegistration::new()`](registration::PluginRegistration::new) constructor
150//!   automatically embeds the [`SDK_VERSION`](registration::SDK_VERSION) constant.
151//!   The server checks this at load time and **rejects plugins built with a different
152//!   SDK version**.
153//!
154//! ### Step 4: Build and deploy
155//!
156//! ```bash
157//! cargo build --release
158//! # Copy the shared library to the server's plugins directory
159//! cp target/release/libmy_plugin.so /path/to/plugins/
160//! ```
161//!
162//! ### Compatibility Requirements
163//!
164//! Both the plugin and the server **must** be compiled with:
165//!
166//! - The **same Rust toolchain** version (the Rust ABI is not stable across versions).
167//! - The **same `drasi-plugin-sdk` version**. The server compares
168//!   [`SDK_VERSION`](registration::SDK_VERSION) at load time and rejects mismatches.
169//!
170//! Failing to meet these requirements will result in the plugin being rejected at
171//! load time or, in the worst case, undefined behavior from ABI incompatibility.
172//!
173//! ## Modules
174//!
175//! - [`config_value`] — The [`ConfigValue<T>`](config_value::ConfigValue) enum for
176//!   configuration fields that support static values, environment variables, and secrets.
177//! - [`resolver`] — Value resolvers that convert config references to actual values.
178//! - [`mapper`] — The [`DtoMapper`](mapper::DtoMapper) service and [`ConfigMapper`](mapper::ConfigMapper)
179//!   trait for DTO-to-domain conversions.
180//! - [`descriptor`] — Plugin descriptor traits
181//!   ([`SourcePluginDescriptor`](descriptor::SourcePluginDescriptor),
182//!   [`ReactionPluginDescriptor`](descriptor::ReactionPluginDescriptor),
183//!   [`BootstrapPluginDescriptor`](descriptor::BootstrapPluginDescriptor)).
184//! - [`registration`] — The [`PluginRegistration`](registration::PluginRegistration) struct
185//!   returned by plugin entry points.
186//! - [`prelude`] — Convenience re-exports for plugin authors.
187//!
188//! ## Configuration Values
189//!
190//! Plugin DTOs use [`ConfigValue<T>`](config_value::ConfigValue) for fields that may
191//! be provided as static values, environment variable references, or secret references.
192//! See the [`config_value`] module for the full documentation and supported formats.
193//!
194//! ## OpenAPI Schema Generation
195//!
196//! Each plugin provides its configuration schema as a JSON-serialized utoipa `Schema`.
197//! The server deserializes these schemas and assembles them into the OpenAPI specification.
198//! This approach preserves strongly-typed OpenAPI documentation while keeping schema
199//! ownership with the plugins.
200//!
201//! ## DTO Versioning
202//!
203//! Each plugin independently versions its configuration DTO using semver. The server
204//! tracks config versions and can reject incompatible plugins. See the [`descriptor`]
205//! module docs for versioning rules.
206
207pub mod config_value;
208pub mod descriptor;
209pub mod ffi;
210pub mod mapper;
211pub mod prelude;
212pub mod registration;
213pub mod resolver;
214
215// Top-level re-exports for convenience
216pub use config_value::ConfigValue;
217pub use descriptor::{
218    BootstrapPluginDescriptor, IdentityProviderPluginDescriptor, ReactionPluginDescriptor,
219    SourcePluginDescriptor,
220};
221pub use mapper::{ConfigMapper, DtoMapper, MappingError};
222pub use registration::{PluginRegistration, SDK_VERSION};
223pub use resolver::{register_secret_resolver, ResolverError};
224
225/// Re-export tokio so the `export_plugin!` macro can reference it
226/// without requiring plugins to declare a direct tokio dependency.
227#[doc(hidden)]
228pub use tokio as __tokio;
229
230/// Export dynamic plugin entry points with FFI vtables.
231///
232/// Generates:
233/// - `drasi_plugin_metadata()` → version info for validation
234/// - `drasi_plugin_init()` → `FfiPluginRegistration` with vtable factories
235/// - Plugin-local tokio runtime, FfiLogger, lifecycle callbacks
236///
237/// # Usage
238///
239/// ```rust,ignore
240/// drasi_plugin_sdk::export_plugin!(
241///     plugin_id = "postgres",
242///     core_version = "0.1.0",
243///     lib_version = "0.3.8",
244///     plugin_version = "1.0.0",
245///     source_descriptors = [PostgresSourceDescriptor],
246///     reaction_descriptors = [],
247///     bootstrap_descriptors = [PostgresBootstrapDescriptor],
248/// );
249/// ```
250///
251/// An optional `worker_threads` parameter sets the default number of tokio
252/// worker threads for the plugin's runtime (default: 2). This can be
253/// overridden at deploy time via the `DRASI_PLUGIN_WORKERS` environment
254/// variable.
255///
256/// ```rust,ignore
257/// drasi_plugin_sdk::export_plugin!(
258///     plugin_id = "postgres",
259///     // ...
260///     bootstrap_descriptors = [PostgresBootstrapDescriptor],
261///     identity_provider_descriptors = [],
262///     worker_threads = 4,
263/// );
264/// ```
265#[macro_export]
266macro_rules! export_plugin {
267    // ── Declarative form: descriptors listed inline ──
268    (
269        plugin_id = $plugin_id:expr,
270        core_version = $core_ver:expr,
271        lib_version = $lib_ver:expr,
272        plugin_version = $plugin_ver:expr,
273        source_descriptors = [ $($source_desc:expr),* $(,)? ],
274        reaction_descriptors = [ $($reaction_desc:expr),* $(,)? ],
275        bootstrap_descriptors = [ $($bootstrap_desc:expr),* $(,)? ],
276        identity_provider_descriptors = [ $($ip_desc:expr),* $(,)? ],
277        worker_threads = $workers:expr $(,)?
278    ) => {
279        fn __auto_create_plugin_vtables() -> (
280            Vec<$crate::ffi::SourcePluginVtable>,
281            Vec<$crate::ffi::ReactionPluginVtable>,
282            Vec<$crate::ffi::BootstrapPluginVtable>,
283            Vec<$crate::ffi::IdentityProviderPluginVtable>,
284        ) {
285            let source_descs = vec![
286                $( $crate::ffi::build_source_plugin_vtable(
287                    $source_desc,
288                    __plugin_executor,
289                    __emit_lifecycle,
290                    __plugin_runtime,
291                ), )*
292            ];
293            let reaction_descs = vec![
294                $( $crate::ffi::build_reaction_plugin_vtable(
295                    $reaction_desc,
296                    __plugin_executor,
297                    __emit_lifecycle,
298                    __plugin_runtime,
299                ), )*
300            ];
301            let bootstrap_descs = vec![
302                $( $crate::ffi::build_bootstrap_plugin_vtable(
303                    $bootstrap_desc,
304                    __plugin_executor,
305                    __emit_lifecycle,
306                    __plugin_runtime,
307                ), )*
308            ];
309            let identity_provider_descs = vec![
310                $( $crate::ffi::build_identity_provider_plugin_vtable(
311                    $ip_desc,
312                    __plugin_executor,
313                    __emit_lifecycle,
314                    __plugin_runtime,
315                ), )*
316            ];
317            (source_descs, reaction_descs, bootstrap_descs, identity_provider_descs)
318        }
319
320        $crate::export_plugin!(
321            @internal
322            plugin_id = $plugin_id,
323            core_version = $core_ver,
324            lib_version = $lib_ver,
325            plugin_version = $plugin_ver,
326            init_fn = __auto_create_plugin_vtables,
327            default_workers = $workers,
328        );
329    };
330    // ── Declarative form: descriptors listed inline (default worker threads) ──
331    (
332        plugin_id = $plugin_id:expr,
333        core_version = $core_ver:expr,
334        lib_version = $lib_ver:expr,
335        plugin_version = $plugin_ver:expr,
336        source_descriptors = [ $($source_desc:expr),* $(,)? ],
337        reaction_descriptors = [ $($reaction_desc:expr),* $(,)? ],
338        bootstrap_descriptors = [ $($bootstrap_desc:expr),* $(,)? ],
339        identity_provider_descriptors = [ $($ip_desc:expr),* $(,)? ] $(,)?
340    ) => {
341        fn __auto_create_plugin_vtables() -> (
342            Vec<$crate::ffi::SourcePluginVtable>,
343            Vec<$crate::ffi::ReactionPluginVtable>,
344            Vec<$crate::ffi::BootstrapPluginVtable>,
345            Vec<$crate::ffi::IdentityProviderPluginVtable>,
346        ) {
347            let source_descs = vec![
348                $( $crate::ffi::build_source_plugin_vtable(
349                    $source_desc,
350                    __plugin_executor,
351                    __emit_lifecycle,
352                    __plugin_runtime,
353                ), )*
354            ];
355            let reaction_descs = vec![
356                $( $crate::ffi::build_reaction_plugin_vtable(
357                    $reaction_desc,
358                    __plugin_executor,
359                    __emit_lifecycle,
360                    __plugin_runtime,
361                ), )*
362            ];
363            let bootstrap_descs = vec![
364                $( $crate::ffi::build_bootstrap_plugin_vtable(
365                    $bootstrap_desc,
366                    __plugin_executor,
367                    __emit_lifecycle,
368                    __plugin_runtime,
369                ), )*
370            ];
371            let identity_provider_descs = vec![
372                $( $crate::ffi::build_identity_provider_plugin_vtable(
373                    $ip_desc,
374                    __plugin_executor,
375                    __emit_lifecycle,
376                    __plugin_runtime,
377                ), )*
378            ];
379            (source_descs, reaction_descs, bootstrap_descs, identity_provider_descs)
380        }
381
382        $crate::export_plugin!(
383            @internal
384            plugin_id = $plugin_id,
385            core_version = $core_ver,
386            lib_version = $lib_ver,
387            plugin_version = $plugin_ver,
388            init_fn = __auto_create_plugin_vtables,
389            default_workers = 2usize,
390        );
391    };
392    // ── Backward-compatible form: no identity_provider_descriptors (with worker_threads) ──
393    (
394        plugin_id = $plugin_id:expr,
395        core_version = $core_ver:expr,
396        lib_version = $lib_ver:expr,
397        plugin_version = $plugin_ver:expr,
398        source_descriptors = [ $($source_desc:expr),* $(,)? ],
399        reaction_descriptors = [ $($reaction_desc:expr),* $(,)? ],
400        bootstrap_descriptors = [ $($bootstrap_desc:expr),* $(,)? ],
401        worker_threads = $workers:expr $(,)?
402    ) => {
403        $crate::export_plugin!(
404            plugin_id = $plugin_id,
405            core_version = $core_ver,
406            lib_version = $lib_ver,
407            plugin_version = $plugin_ver,
408            source_descriptors = [ $($source_desc),* ],
409            reaction_descriptors = [ $($reaction_desc),* ],
410            bootstrap_descriptors = [ $($bootstrap_desc),* ],
411            identity_provider_descriptors = [],
412            worker_threads = $workers,
413        );
414    };
415    // ── Backward-compatible form: no identity_provider_descriptors (default workers) ──
416    (
417        plugin_id = $plugin_id:expr,
418        core_version = $core_ver:expr,
419        lib_version = $lib_ver:expr,
420        plugin_version = $plugin_ver:expr,
421        source_descriptors = [ $($source_desc:expr),* $(,)? ],
422        reaction_descriptors = [ $($reaction_desc:expr),* $(,)? ],
423        bootstrap_descriptors = [ $($bootstrap_desc:expr),* $(,)? ] $(,)?
424    ) => {
425        $crate::export_plugin!(
426            plugin_id = $plugin_id,
427            core_version = $core_ver,
428            lib_version = $lib_ver,
429            plugin_version = $plugin_ver,
430            source_descriptors = [ $($source_desc),* ],
431            reaction_descriptors = [ $($reaction_desc),* ],
432            bootstrap_descriptors = [ $($bootstrap_desc),* ],
433            identity_provider_descriptors = [],
434        );
435    };
436
437    // ── Internal form: custom init function ──
438    (
439        @internal
440        plugin_id = $plugin_id:expr,
441        core_version = $core_ver:expr,
442        lib_version = $lib_ver:expr,
443        plugin_version = $plugin_ver:expr,
444        init_fn = $init_fn:ident,
445        default_workers = $default_workers:expr $(,)?
446    ) => {
447        // ── Tokio runtime (accessible to plugin code) ──
448        //
449        // The runtime is stored behind an `AtomicPtr` so that
450        // `drasi_plugin_shutdown()` can take ownership and call
451        // `shutdown_timeout()` to cleanly stop all worker threads.
452        // A `OnceLock<()>` ensures one-time initialization.
453        static __RT_INIT: ::std::sync::OnceLock<()> = ::std::sync::OnceLock::new();
454        static __RT_PTR: ::std::sync::atomic::AtomicPtr<$crate::__tokio::runtime::Runtime> =
455            ::std::sync::atomic::AtomicPtr::new(::std::ptr::null_mut());
456
457        fn __init_plugin_runtime() {
458            let default_threads: usize = $default_workers;
459            let kind_var = format!(
460                "DRASI_PLUGIN_WORKERS_{}",
461                $plugin_id.to_uppercase().replace('-', "_")
462            );
463            let threads = ::std::env::var(&kind_var)
464                .ok()
465                .and_then(|v| v.parse().ok())
466                .or_else(|| {
467                    ::std::env::var("DRASI_PLUGIN_WORKERS")
468                        .ok()
469                        .and_then(|v| v.parse().ok())
470                })
471                .unwrap_or(default_threads);
472            let rt = Box::new(
473                $crate::__tokio::runtime::Builder::new_multi_thread()
474                    .worker_threads(threads)
475                    .enable_all()
476                    .thread_name(concat!($plugin_id, "-worker"))
477                    .build()
478                    .expect("Failed to create plugin tokio runtime"),
479            );
480            __RT_PTR.store(Box::into_raw(rt), ::std::sync::atomic::Ordering::Release);
481        }
482
483        pub fn __plugin_runtime() -> &'static $crate::__tokio::runtime::Runtime {
484            __RT_INIT.get_or_init(|| __init_plugin_runtime());
485            // Safety: after init, __RT_PTR is non-null and valid for 'static
486            // until drasi_plugin_shutdown() is called.
487            unsafe { &*__RT_PTR.load(::std::sync::atomic::Ordering::Acquire) }
488        }
489
490        /// Shut down the plugin's tokio runtime, stopping all worker threads.
491        ///
492        /// Must be called before dlclose / library unload. After this call,
493        /// any `&'static Runtime` references obtained from `__plugin_runtime()`
494        /// are dangling — no further FFI calls into this plugin are safe.
495        #[no_mangle]
496        pub extern "C" fn drasi_plugin_shutdown() {
497            // Just null the pointer so no further FFI calls use the runtime.
498            // The runtime itself is intentionally leaked — its worker threads
499            // will be killed when the process exits.
500            __RT_PTR.swap(
501                ::std::ptr::null_mut(),
502                ::std::sync::atomic::Ordering::AcqRel,
503            );
504        }
505
506        struct __SendPtr(*mut ::std::ffi::c_void);
507        unsafe impl Send for __SendPtr {}
508
509        /// Async executor dispatching to this plugin's tokio runtime.
510        pub extern "C" fn __plugin_executor(
511            future_ptr: *mut ::std::ffi::c_void,
512        ) -> *mut ::std::ffi::c_void {
513            let boxed: Box<
514                ::std::pin::Pin<
515                    Box<dyn ::std::future::Future<Output = *mut ::std::ffi::c_void> + Send>,
516                >,
517            > = unsafe { Box::from_raw(future_ptr as *mut _) };
518            let handle = __plugin_runtime().handle().clone();
519            let (tx, rx) = ::std::sync::mpsc::sync_channel::<__SendPtr>(0);
520            handle.spawn(async move {
521                let raw = (*boxed).await;
522                let _ = tx.send(__SendPtr(raw));
523            });
524            rx.recv().expect("Plugin executor task dropped").0
525        }
526
527        /// Run an async future on the plugin runtime, blocking until complete.
528        #[allow(dead_code)]
529        pub fn plugin_block_on<F>(f: F) -> F::Output
530        where
531            F: ::std::future::Future + Send + 'static,
532            F::Output: Send + 'static,
533        {
534            let handle = __plugin_runtime().handle().clone();
535            ::std::thread::spawn(move || handle.block_on(f))
536                .join()
537                .expect("plugin_block_on: spawned thread panicked")
538        }
539
540        // ── Log/lifecycle callback storage ──
541        static __LOG_CB: ::std::sync::atomic::AtomicPtr<()> =
542            ::std::sync::atomic::AtomicPtr::new(::std::ptr::null_mut());
543        static __LOG_CTX: ::std::sync::atomic::AtomicPtr<::std::ffi::c_void> =
544            ::std::sync::atomic::AtomicPtr::new(::std::ptr::null_mut());
545        static __LIFECYCLE_CB: ::std::sync::atomic::AtomicPtr<()> =
546            ::std::sync::atomic::AtomicPtr::new(::std::ptr::null_mut());
547        static __LIFECYCLE_CTX: ::std::sync::atomic::AtomicPtr<::std::ffi::c_void> =
548            ::std::sync::atomic::AtomicPtr::new(::std::ptr::null_mut());
549
550        // Note: FfiLogger (log::Log) is no longer used. All log crate events are
551        // bridged to tracing via tracing-log's LogTracer, then handled by
552        // FfiTracingLayer which has access to span context for correct routing.
553
554        extern "C" fn __set_log_callback_impl(
555            ctx: *mut ::std::ffi::c_void,
556            callback: $crate::ffi::LogCallbackFn,
557        ) {
558            __LOG_CTX.store(ctx, ::std::sync::atomic::Ordering::Release);
559            __LOG_CB.store(callback as *mut (), ::std::sync::atomic::Ordering::Release);
560
561            // Set up tracing subscriber with LogTracer bridge.
562            // LogTracer redirects log crate events → tracing, and FfiTracingLayer
563            // forwards all tracing events (including log-bridged ones) through FFI
564            // with span context for correct routing.
565            $crate::ffi::tracing_bridge::init_tracing_subscriber(
566                &__LOG_CB,
567                &__LOG_CTX,
568                $plugin_id,
569            );
570        }
571
572        extern "C" fn __set_lifecycle_callback_impl(
573            ctx: *mut ::std::ffi::c_void,
574            callback: $crate::ffi::LifecycleCallbackFn,
575        ) {
576            __LIFECYCLE_CTX.store(ctx, ::std::sync::atomic::Ordering::Release);
577            __LIFECYCLE_CB.store(
578                callback as *mut (),
579                ::std::sync::atomic::Ordering::Release,
580            );
581        }
582
583        /// Emit a lifecycle event to the host.
584        pub fn __emit_lifecycle(
585            component_id: &str,
586            event_type: $crate::ffi::FfiLifecycleEventType,
587            message: &str,
588        ) {
589            let ptr = __LIFECYCLE_CB.load(::std::sync::atomic::Ordering::Acquire);
590            if !ptr.is_null() {
591                let cb: $crate::ffi::LifecycleCallbackFn =
592                    unsafe { ::std::mem::transmute(ptr) };
593                let ctx = __LIFECYCLE_CTX.load(::std::sync::atomic::Ordering::Acquire);
594                let event = $crate::ffi::FfiLifecycleEvent {
595                    component_id: $crate::ffi::FfiStr::from_str(component_id),
596                    component_type: $crate::ffi::FfiStr::from_str("plugin"),
597                    event_type,
598                    message: $crate::ffi::FfiStr::from_str(message),
599                    timestamp_us: $crate::ffi::now_us(),
600                };
601                cb(ctx, &event);
602            }
603        }
604
605        // ── Plugin metadata ──
606        static __PLUGIN_METADATA: $crate::ffi::PluginMetadata = $crate::ffi::PluginMetadata {
607            sdk_version: $crate::ffi::FfiStr {
608                ptr: $crate::ffi::FFI_SDK_VERSION.as_ptr() as *const ::std::os::raw::c_char,
609                len: $crate::ffi::FFI_SDK_VERSION.len(),
610            },
611            core_version: $crate::ffi::FfiStr {
612                ptr: $core_ver.as_ptr() as *const ::std::os::raw::c_char,
613                len: $core_ver.len(),
614            },
615            lib_version: $crate::ffi::FfiStr {
616                ptr: $lib_ver.as_ptr() as *const ::std::os::raw::c_char,
617                len: $lib_ver.len(),
618            },
619            plugin_version: $crate::ffi::FfiStr {
620                ptr: $plugin_ver.as_ptr() as *const ::std::os::raw::c_char,
621                len: $plugin_ver.len(),
622            },
623            target_triple: $crate::ffi::FfiStr {
624                ptr: $crate::ffi::TARGET_TRIPLE.as_ptr() as *const ::std::os::raw::c_char,
625                len: $crate::ffi::TARGET_TRIPLE.len(),
626            },
627            git_commit: $crate::ffi::FfiStr {
628                ptr: $crate::ffi::GIT_COMMIT_SHA.as_ptr() as *const ::std::os::raw::c_char,
629                len: $crate::ffi::GIT_COMMIT_SHA.len(),
630            },
631            build_timestamp: $crate::ffi::FfiStr {
632                ptr: $crate::ffi::BUILD_TIMESTAMP.as_ptr() as *const ::std::os::raw::c_char,
633                len: $crate::ffi::BUILD_TIMESTAMP.len(),
634            },
635        };
636
637        /// Returns plugin metadata for version validation. Called BEFORE init.
638        #[no_mangle]
639        pub extern "C" fn drasi_plugin_metadata() -> *const $crate::ffi::PluginMetadata {
640            &__PLUGIN_METADATA
641        }
642
643        /// Plugin entry point. Called AFTER metadata validation passes.
644        #[no_mangle]
645        pub extern "C" fn drasi_plugin_init() -> *mut $crate::ffi::FfiPluginRegistration {
646            match ::std::panic::catch_unwind(|| {
647                let _ = __plugin_runtime();
648                let (mut source_descs, mut reaction_descs, mut bootstrap_descs, mut identity_provider_descs) = $init_fn();
649
650                let registration = Box::new($crate::ffi::FfiPluginRegistration {
651                    source_plugins: source_descs.as_mut_ptr(),
652                    source_plugin_count: source_descs.len(),
653                    reaction_plugins: reaction_descs.as_mut_ptr(),
654                    reaction_plugin_count: reaction_descs.len(),
655                    bootstrap_plugins: bootstrap_descs.as_mut_ptr(),
656                    bootstrap_plugin_count: bootstrap_descs.len(),
657                    identity_provider_plugins: identity_provider_descs.as_mut_ptr(),
658                    identity_provider_plugin_count: identity_provider_descs.len(),
659                    set_log_callback: __set_log_callback_impl,
660                    set_lifecycle_callback: __set_lifecycle_callback_impl,
661                });
662                ::std::mem::forget(source_descs);
663                ::std::mem::forget(reaction_descs);
664                ::std::mem::forget(bootstrap_descs);
665                ::std::mem::forget(identity_provider_descs);
666                Box::into_raw(registration)
667            }) {
668                Ok(ptr) => ptr,
669                Err(_) => ::std::ptr::null_mut(),
670            }
671        }
672    };
673}