drasi-plugin-sdk 0.4.2

SDK for building Drasi plugins (sources, reactions, bootstrappers)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
# Drasi Plugin SDK

The Drasi Plugin SDK provides traits, types, and utilities for building plugins for the any application that implements the [Host SDK](../host-sdk/). Plugins extend the server with new data sources, reactions, and bootstrap providers.

Plugins can be compiled directly into the server binary (**static linking**) or built as shared libraries for **dynamic loading** at runtime.

## Quick Start

Add the SDK to your plugin crate:

```toml
[dependencies]
drasi-plugin-sdk = { workspace = true }
drasi-lib = { workspace = true }
```

Import the prelude:

```rust
use drasi_plugin_sdk::prelude::*;
```

## Plugin Types

The SDK defines three plugin categories, each with a corresponding descriptor trait:

| Plugin Type | Trait | Purpose |
|---|---|---|
| **Source** | `SourcePluginDescriptor` | Ingests data from external systems (databases, APIs, queues) |
| **Reaction** | `ReactionPluginDescriptor` | Consumes query results and performs side effects (webhooks, logging, stored procedures) |
| **Bootstrap** | `BootstrapPluginDescriptor` | Provides initial data snapshots so queries start with a complete view |

## Writing a Plugin

### 1. Define your configuration DTO

Use `ConfigValue<T>` for fields that may be provided as static values, environment variable references, or secret references. Derive `utoipa::ToSchema` for OpenAPI schema generation.

```rust
use drasi_plugin_sdk::prelude::*;

#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct MySourceConfigDto {
    /// The hostname to connect to
    #[schema(value_type = ConfigValueString)]
    pub host: ConfigValue<String>,

    /// The port number
    #[schema(value_type = ConfigValueU16)]
    pub port: ConfigValue<u16>,

    /// Optional connection timeout in milliseconds
    #[serde(skip_serializing_if = "Option::is_none")]
    #[schema(value_type = Option<ConfigValueU32>)]
    pub timeout_ms: Option<ConfigValue<u32>>,
}
```

### 2. Implement a descriptor trait

Each descriptor provides:

- **`kind()`** — A unique string identifier (e.g., `"postgres"`, `"http"`)
- **`config_version()`** — A semver version for the configuration DTO
- **`config_schema_json()`** — A JSON-serialized OpenAPI schema map
- **`config_schema_name()`** — The schema name used as the key in the OpenAPI spec
- **`create_*()`** — A factory method that builds a plugin instance from raw JSON config

```rust
use drasi_plugin_sdk::prelude::*;
use utoipa::OpenApi;

pub struct MySourceDescriptor;

// Collect all transitive schemas via utoipa's OpenApi derive
#[derive(OpenApi)]
#[openapi(schemas(MySourceConfigDto))]
struct MySourceSchemas;

#[async_trait]
impl SourcePluginDescriptor for MySourceDescriptor {
    fn kind(&self) -> &str { "my-source" }
    fn config_version(&self) -> &str { "1.0.0" }

    fn config_schema_json(&self) -> String {
        let api = MySourceSchemas::openapi();
        serde_json::to_string(&api.components.as_ref().unwrap().schemas).unwrap()
    }

    fn config_schema_name(&self) -> &str { "MySourceConfigDto" }

    async fn create_source(
        &self,
        id: &str,
        config_json: &serde_json::Value,
        auto_start: bool,
    ) -> anyhow::Result<Box<dyn drasi_lib::sources::Source>> {
        let dto: MySourceConfigDto = serde_json::from_value(config_json.clone())?;
        let mapper = DtoMapper::new();
        let host = mapper.resolve_string(&dto.host)?;
        let port = mapper.resolve_typed(&dto.port)?;
        // ... build and return the source
        todo!()
    }
}
```

### 3. Register the plugin

#### For static plugins

Bundle your descriptors into a `PluginRegistration` and pass them to the server at startup:

```rust
use drasi_plugin_sdk::prelude::*;

pub fn register() -> PluginRegistration {
    PluginRegistration::new()
        .with_source(Box::new(MySourceDescriptor))
        .with_bootstrapper(Box::new(MyBootstrapDescriptor))
}
```

#### For dynamic plugins

Use the `export_plugin!` macro, which generates the FFI entry points, a dedicated tokio runtime, and log/lifecycle callback wiring:

```rust
drasi_plugin_sdk::export_plugin!(
    plugin_id = "my-source",
    core_version = env!("CARGO_PKG_VERSION"),
    lib_version = env!("CARGO_PKG_VERSION"),
    plugin_version = env!("CARGO_PKG_VERSION"),
    source_descriptors = [MySourceDescriptor],
    reaction_descriptors = [],
    bootstrap_descriptors = [MyBootstrapDescriptor],
);
```

## Static vs. Dynamic Plugins

### Static Linking

Bundle your descriptors directly into a Rust binary using `PluginRegistration`. This is only applicable when using `drasi-lib` programmatically (embedded/library use) — no shared library boundary, no ABI concerns. The standalone `drasi-server` always uses dynamic loading.

### Dynamic Loading

Build the plugin as a `cdylib` shared library that the server discovers and loads at runtime. This is the standard deployment model for the standalone server — dynamic loading is always enabled with no feature flags required on the server side.

#### Step 1: Configure crate type

```toml
[lib]
crate-type = ["lib", "cdylib"]

[dependencies]
drasi-plugin-sdk = { workspace = true }
drasi-lib = { workspace = true }
```

The dual `["lib", "cdylib"]` crate type allows the crate to be used both as a normal Rust dependency (for static builds) and as a shared library (for dynamic loading).

#### Step 2: Use the `export_plugin!` macro

The `export_plugin!` macro generates everything needed for dynamic loading:

- `drasi_plugin_metadata()` — Returns version info for validation **before** initialization
- `drasi_plugin_init()` — Returns an `FfiPluginRegistration` with FFI vtable factories
- A dedicated tokio runtime for the plugin
- Log and lifecycle callback bridges (tracing integration via `FfiTracingLayer`)

```rust
drasi_plugin_sdk::export_plugin!(
    plugin_id = "postgres",
    core_version = env!("CARGO_PKG_VERSION"),
    lib_version = env!("CARGO_PKG_VERSION"),
    plugin_version = env!("CARGO_PKG_VERSION"),
    source_descriptors = [PostgresSourceDescriptor],
    reaction_descriptors = [],
    bootstrap_descriptors = [PostgresBootstrapDescriptor],
);
```

Each descriptor is wrapped in an FFI vtable (`SourcePluginVtable`, `ReactionPluginVtable`, `BootstrapPluginVtable`) that provides `#[repr(C)]` function pointers. When the host calls a factory method (e.g., `create_source_fn`), the plugin constructs a trait object and wraps it in a component vtable (`SourceVtable`, `ReactionVtable`, `BootstrapProviderVtable`) with its own FFI-safe function pointers.

#### Step 3: Build and deploy

```bash
# Build the plugin as a cdylib shared library
cargo build --release

# Copy the shared library to the server's plugin directory
cp target/release/libdrasi_source_my_plugin.so /path/to/server/plugins/
```

> **Note:** Individual plugin crates may define a `dynamic-plugin` feature to gate `export_plugin!` macro invocation — check the plugin's `Cargo.toml`.

#### Plugin metadata provenance

`PluginMetadata` includes `git_commit` (from `GIT_COMMIT` env or `git rev-parse HEAD`) and `build_timestamp` (from `BUILD_TIMESTAMP` env or compile time) for build provenance tracking.

#### Plugin signing

When publishing plugins with `cargo xtask publish-plugins --sign`, each plugin is signed using cosign keyless signing (Sigstore). Third-party plugin authors can sign their plugins using `cosign sign --yes <oci-reference>` after publishing.

#### Compatibility requirements

Both the plugin and the server **must** be compiled with:

- The **same Rust toolchain version** — the Rust ABI is not stable across compiler versions.
- The **same `drasi-plugin-sdk` version** — the server compares `SDK_VERSION` at load time and rejects mismatches.
- The **same target triple** — validated via `PluginMetadata::target_triple`.

The server performs a two-phase load:

1. **Metadata validation** — calls `drasi_plugin_metadata()` to check SDK version, build hash, and target triple.
2. **Initialization** — calls `drasi_plugin_init()` only if metadata validation passes.

## FFI Architecture

Dynamic plugins communicate with the host through `#[repr(C)]` vtable structs (stable C ABI). The FFI layer is organized into several modules:

| Module | Description |
|---|---|
| `ffi::types` | Core FFI-safe primitives (`FfiStr`, `FfiResult`, `FfiOwnedStr`, etc.). Re-exported from the `drasi-ffi-primitives` crate. |
| `ffi::vtables` | `#[repr(C)]` vtable structs for all component types, generated with the `ffi_vtable!` macro |
| `ffi::vtable_gen` | Functions that wrap trait objects into vtables (e.g., `build_source_vtable`) |
| `ffi::callbacks` | Log and lifecycle callback types (`LogCallbackFn`, `LifecycleCallbackFn`) |
| `ffi::metadata` | `PluginMetadata` struct and version constants for load-time validation |
| `ffi::tracing_bridge` | Bridges tracing/log events from the plugin to the host via FFI callbacks |
| `ffi::identity` | FFI types for `IdentityProvider` injection into plugins |
| `ffi::identity_proxy` | Plugin-side proxy that implements `IdentityProvider` over an FFI vtable |
| `ffi::state_store_proxy` | Plugin-side proxy that implements `StateStoreProvider` over an FFI vtable |
| `ffi::bootstrap_proxy` | Plugin-side proxy that implements `BootstrapProvider` over an FFI vtable |

Each plugin gets its own tokio runtime (created by the `export_plugin!` macro). Synchronous vtable calls (e.g., `start`, `stop`) use `dispatch_to_runtime` with a `std::sync::mpsc::sync_channel(0)` rendezvous to bridge into the plugin's async runtime. However, the primary data paths use a **push-based** model that avoids per-event `dispatch_to_runtime` overhead.

### Push-Based Data Delivery

All high-throughput data paths use a push model where a forwarder task is spawned on one side and events are pushed via a callback into a channel on the other side:

- **Source change events**: The host calls `start_push_fn` with a callback. The plugin spawns a forwarder task on its runtime that reads from the underlying change channel and invokes the callback for each event. This avoids per-event `dispatch_to_runtime` overhead (~0.3µs vs ~5-20µs for rendezvous dispatch).
- **Bootstrap events**: Same push pattern — the plugin spawns a forwarder that pushes bootstrap events (a finite stream) into a host-side channel via a callback.
- **Reaction query results**: Reversed direction — `start_result_push_fn` lets the host push query results into a channel. The plugin's forwarder drains them via a blocking callback (`spawn_blocking` + `std::sync::mpsc::Receiver::recv`).

Typical FFI overhead through the full push pipeline is ~1-2µs per change event.

### Cross-cdylib Channel Safety

All channels that cross the cdylib boundary use `std::sync::mpsc` (not `tokio::sync::mpsc`). Each cdylib has its own statically-linked copy of tokio with incompatible internal state, so `tokio::sync` channels cannot be shared across the boundary. Host-side proxies bridge from `std::sync::mpsc` into tokio channels using forwarding tasks or `tokio::sync::Notify` for async wakeup.

### Fire-and-Forget Host Callbacks

Host callbacks for logging and lifecycle events use fire-and-forget `tokio::spawn` on the host runtime (via `run_on_host_runtime`) rather than blocking rendezvous. This avoids deadlocks when the host runtime is single-threaded (e.g., in test environments). The plugin invokes the `extern "C"` callback synchronously, but the host side immediately spawns an async task and returns.

## Configuration Values

`ConfigValue<T>` is the building block for plugin configuration fields. It supports three input formats:

### Static value

```yaml
host: "localhost"
port: 5432
```

### POSIX environment variable reference

```yaml
host: "${DB_HOST}"
port: "${DB_PORT:-5432}"
```

### Structured reference

```yaml
password:
  kind: Secret
  name: db-password
port:
  kind: EnvironmentVariable
  name: DB_PORT
  default: "5432"
```

### Type aliases

| Alias | Type |
|---|---|
| `ConfigValueString` | `ConfigValue<String>` |
| `ConfigValueU16` | `ConfigValue<u16>` |
| `ConfigValueU32` | `ConfigValue<u32>` |
| `ConfigValueU64` | `ConfigValue<u64>` |
| `ConfigValueUsize` | `ConfigValue<usize>` |
| `ConfigValueBool` | `ConfigValue<bool>` |

Each alias has a corresponding `*Schema` wrapper type for use with `#[schema(value_type = ...)]` annotations in utoipa.

## Resolving Configuration Values

Use `DtoMapper` to resolve `ConfigValue` references to their actual values:

```rust
let mapper = DtoMapper::new();

// Resolve to string
let host: String = mapper.resolve_string(&dto.host)?;

// Resolve to typed value (parses string → T via FromStr)
let port: u16 = mapper.resolve_typed(&dto.port)?;

// Resolve optional fields
let timeout: Option<u32> = mapper.resolve_optional(&dto.timeout_ms)?;

// Resolve a vec of string values
let tags: Vec<String> = mapper.resolve_string_vec(&dto.tags)?;
```

### Built-in resolvers

| Resolver | Handles | Behavior |
|---|---|---|
| `EnvironmentVariableResolver` | `ConfigValue::EnvironmentVariable` | Reads `std::env::var()`, falls back to default |
| `SecretResolver` | `ConfigValue::Secret` | Delegates to a pluggable `SecretResolver` registered via `register_secret_resolver()` |

### Custom resolvers

Implement `ValueResolver` to add custom resolution logic (e.g., HashiCorp Vault, AWS SSM):

```rust
use drasi_plugin_sdk::prelude::*;

struct VaultResolver { /* vault client */ }

impl ValueResolver for VaultResolver {
    fn resolve_to_string(
        &self,
        value: &ConfigValue<String>,
    ) -> Result<String, ResolverError> {
        match value {
            ConfigValue::Secret { name } => {
                // Look up secret in Vault
                Ok("resolved-secret-value".to_string())
            }
            _ => Err(ResolverError::WrongResolverType),
        }
    }
}

let mapper = DtoMapper::new()
    .with_resolver("Secret", Box::new(VaultResolver { /* ... */ }));
```

## DTO Versioning

Each plugin independently versions its configuration DTO via the `config_version()` method using semver:

| Change | Version Bump | Example |
|---|---|---|
| Field removed or renamed | **Major** | `1.0.0``2.0.0` |
| Field type changed | **Major** | `1.0.0``2.0.0` |
| New optional field added | **Minor** | `1.0.0``1.1.0` |
| Documentation or description change | **Patch** | `1.0.0``1.0.1` |

## OpenAPI Schema Generation

Plugins provide their configuration schemas as JSON-serialized utoipa schema maps. The server collects these from all registered plugins and assembles them into the OpenAPI specification. This keeps schema ownership with the plugins while allowing the server to build a unified API spec.

The recommended pattern is to use `#[derive(OpenApi)]` to automatically collect all transitive schema dependencies:

```rust
use utoipa::OpenApi;

#[derive(OpenApi)]
#[openapi(schemas(MySourceConfigDto))]
struct MyPluginSchemas;

fn config_schema_json(&self) -> String {
    let api = MyPluginSchemas::openapi();
    serde_json::to_string(&api.components.as_ref().unwrap().schemas).unwrap()
}

fn config_schema_name(&self) -> &str {
    "MySourceConfigDto"
}
```

## Modules

| Module | Description |
|---|---|
| `config_value` | `ConfigValue<T>` enum, type aliases, and OpenAPI schema wrappers |
| `descriptor` | Plugin descriptor traits (`SourcePluginDescriptor`, `ReactionPluginDescriptor`, `BootstrapPluginDescriptor`) |
| `ffi` | FFI layer for dynamic plugin loading — vtables, callbacks, proxies, tracing bridge |
| `mapper` | `DtoMapper` service and `ConfigMapper` trait for DTO-to-domain conversions |
| `registration` | `PluginRegistration` struct, `SDK_VERSION`, `BUILD_HASH`, and `TOKIO_VERSION` constants |
| `resolver` | `ValueResolver` trait and built-in implementations |
| `prelude` | Convenience re-exports for plugin authors |

## License

Licensed under the [Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0).