Skip to content

Commit

Permalink
Merge pull request bottlerocket-os#38 from bcressey/settings-plugins
Browse files Browse the repository at this point in the history
feat: add crates for settings plugins
  • Loading branch information
bcressey authored Jun 11, 2024
2 parents e6197a8 + a621ee8 commit d51d948
Show file tree
Hide file tree
Showing 7 changed files with 364 additions and 2 deletions.
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,6 @@ members = [
"bottlerocket-settings-sdk",
"bottlerocket-template-helper",
"bottlerocket-defaults-helper",
"bottlerocket-settings-plugin",
"bottlerocket-settings-derive",
]
16 changes: 16 additions & 0 deletions bottlerocket-settings-derive/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
[package]
name = "bottlerocket-settings-derive"
version = "0.1.0"
edition = "2021"
license = "Apache-2.0 OR MIT"
repository = "https://github.com/bottlerocket-os/bottlerocket-settings-sdk"
readme = "../README.md"

[lib]
proc-macro = true

[dependencies]
darling = "0.20.8"
proc-macro2 = "1.0.81"
quote = "1.0.36"
syn = "2.0.60"
71 changes: 71 additions & 0 deletions bottlerocket-settings-derive/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*!
This crate provides a derive macro for implementing the provider side of a Bottlerocket settings
plugin. It should be applied to a custom settings struct in the cdylib crate, and implements the
FFI protocol expected by the host process that will load the plugin.
*/

use darling::{FromDeriveInput, ToTokens};
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};

/// A macro to simplify implementing a settings plugin.
#[proc_macro_derive(SettingsPlugin)]
pub fn derive_settings(input: TokenStream) -> TokenStream {
// Parse the AST and "deserialize" into SettingsPlugin
let ast = parse_macro_input!(input as DeriveInput);
let n = SettingsPlugin::from_derive_input(&ast).expect("Unable to parse macro arguments");
quote!(#n).into()
}

#[derive(Debug, FromDeriveInput)]
#[darling(supports(struct_named))]
struct SettingsPlugin {
ident: syn::Ident,
}

impl ToTokens for SettingsPlugin {
fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) {
let SettingsPlugin { ident } = self;
tokens.extend(quote! {
// Provide the "serialize" interface expected for this type.
impl<'a> abi_stable::erased_types::SerializeType<'a> for #ident {
type Interface = bottlerocket_settings_plugin::BottlerocketSettingsInterface;

fn serialize_impl(&'a self) -> Result<abi_stable::external_types::RawValueBox, abi_stable::std_types::RBoxError> {
// Call the shared function to serialize to JSON.
bottlerocket_settings_plugin::serialize_json(self)
}
}

// Provide the "deserialize" function that's required for FFI.
// This function refers to the type, but isn't otherwise tied to it or namespaced in
// any way, which means the derive macro can't be used for more than one type in the
// module.
#[abi_stable::sabi_extern_fn]
fn deserialize_settings(s: abi_stable::std_types::RStr<'_>) -> abi_stable::std_types::RResult<bottlerocket_settings_plugin::BottlerocketSettingsProvider, abi_stable::std_types::RBoxError> {
// Call the shared function to deserialize from JSON.
bottlerocket_settings_plugin::deserialize_json::<#ident>(s).map(abi_stable::DynTrait::from_value)
}

// Provide the "defaults" function that's required for FFI.
// This function also refers to the type, with the same caveats as above.
#[abi_stable::sabi_extern_fn]
fn default_settings() -> bottlerocket_settings_plugin::BottlerocketSettingsProvider {
// Requires a Default impl on the type.
abi_stable::DynTrait::from_value(#ident::default())
}

// Make the `deserialize_settings` and `default_settings` functions available via FFI
// as the exported interface for this plugin.
#[abi_stable::export_root_module]
fn get_library() -> bottlerocket_settings_plugin::BottlerocketSettingsPluginRef {
abi_stable::prefix_type::PrefixTypeTrait::leak_into_prefix(
bottlerocket_settings_plugin::BottlerocketSettingsPlugin {
default_settings,
deserialize_settings,
})
}
});
}
}
14 changes: 14 additions & 0 deletions bottlerocket-settings-plugin/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[package]
name = "bottlerocket-settings-plugin"
version = "0.1.0"
license = "Apache-2.0 OR MIT"
edition = "2021"
repository = "https://github.com/bottlerocket-os/bottlerocket-settings-sdk"
readme = "../README.md"

[dependencies]
abi_stable = "0.11.3"
lazy_static = "1.4.0"
serde = "1.0.198"
serde_json = "1.0.116"
bottlerocket-settings-derive = { version = "0.1.0", path = "../bottlerocket-settings-derive" }
21 changes: 21 additions & 0 deletions bottlerocket-settings-plugin/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*!
This crate defines the FFI specification for Bottlerocket settings plugins.
The goal of a settings plugin is to enable a host program to construct and serialize instances of a
Rust struct without compile-time access to its definition. Instead, the struct is defined by a
cdylib crate, which can be loaded at runtime into the host program as a plugin. The host program
cannot access fields or methods on this type directly, only through functions exposed via FFI.
The crate also provides helper functionality that can be used by either the host program or by
plugins, to make the shared settings structure easier to implement and to interact with from
idiomatic Rust.
All of the heavy lifting is handled by the abi_stable crate, which provides FFI-safe wrapper types
and an interface for loading and verifying cdylibs at runtime.
*/

mod settings;
pub use settings::*;

// Export the derive macro via this crate, since it depends on the implementation details here.
pub use bottlerocket_settings_derive::SettingsPlugin;
232 changes: 232 additions & 0 deletions bottlerocket-settings-plugin/src/settings.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
/*!
This crate defines the FFI specification for Bottlerocket settings plugins, as well as some helper
functions.
*/

// Avoid empty doc comment warning that originates from the StableAbi derive macro.
#![allow(clippy::empty_docs)]
// Avoid false positive improper ctypes warnings for abi_stable's PhantomData markers. We rely on
// the StableAbi trait to catch any real problems.
#![allow(improper_ctypes_definitions)]

use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::Value as JsonValue;
use std::path::PathBuf;

use abi_stable::{
erased_types::{DeserializeDyn, DynTrait, SerializeProxyType},
external_types::{RawValueBox, RawValueRef},
library::RootModule,
package_version_strings,
sabi_types::VersionStrings,
std_types::{RBox, RBoxError, RErr, ROk, RResult, RStr},
StableAbi,
};

use lazy_static::lazy_static;

const SETTINGS: &str = "settings";

/// Plugins need to provide "default" and "deserialize" functions that return an instance of the
/// opaque BottlerocketSettingsProvider wrapper type. These are the only way for the host program
/// to construct new instances of the underlying concrete type.
#[repr(C)]
#[derive(StableAbi)]
#[sabi(kind(Prefix(prefix_ref = BottlerocketSettingsPluginRef)))]
#[sabi(missing_field(panic))]
pub struct BottlerocketSettingsPlugin {
/// Returns a BottlerocketSettingsProvider that wraps a new instance of the underlying type
/// which was created with default values.
pub default_settings: extern "C" fn() -> BottlerocketSettingsProvider,

#[sabi(last_prefix_field)]
/// Returns a BottlerocketSettingsProvider that wraps a new instance of the underlying type
/// which was created by deserializing the supplied string.
pub deserialize_settings:
for<'a> extern "C" fn(RStr<'a>) -> RResult<BottlerocketSettingsProvider, RBoxError>,
}

/// These values will be checked at runtime to ensure that the host program and the plugin agree
/// on the name and version of the expected interface.
impl RootModule for BottlerocketSettingsPluginRef {
const BASE_NAME: &'static str = SETTINGS;
const NAME: &'static str = SETTINGS;
const VERSION_STRINGS: VersionStrings = package_version_strings!();
abi_stable::declare_root_module_statics! {BottlerocketSettingsPluginRef}
}

// Shared library plugins should only be loaded once, cannot be unloaded, and might not be safe to
// try loading again if the first try fails. Whatever result we get from this attempt is what we'll
// live with.
lazy_static! {
static ref PLUGIN: BottlerocketSettingsPluginRef = {
match BottlerocketSettingsPluginRef::load_from_file(&PathBuf::from(format!(
"lib{}.{}",
BottlerocketSettingsPluginRef::NAME,
std::env::consts::DLL_EXTENSION,
))) {
Ok(r) => r,
Err(e) => {
panic!("Fatal error when loading settings plugin: {e}");
}
}
};
}

/// Provide an interface to load the settings plugin dynamically the first time it's required.
/// Panics if the plugin cannot be loaded. This simplifies loading the plugin since programs do
/// not need to arrange to call an initialization function before the first call to a nominally
/// infallible trait impl like Default.
impl BottlerocketSettingsPluginRef {
pub fn load() {
let _ = *PLUGIN;
}
}

// =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^=

/// Specify all required impls for the wrapped type that will be required by the plugin.
#[repr(C)]
#[derive(StableAbi)]
#[sabi(impl_InterfaceType(
Sync,
Send,
Default,
Eq,
PartialEq,
Clone,
Debug,
Deserialize,
Serialize
))]
pub struct BottlerocketSettingsInterface;

/// Implement the proxy serialization trait for the wrapped type.
impl<'a> SerializeProxyType<'a> for BottlerocketSettingsInterface {
// Serialize the type by way of a boxed serde_json raw value.
type Proxy = RawValueBox;
// There's no need to load the plugin to serialize the type, because the type can only be
// instantiated by the default and deserialize functions, which trigger the plugin load.
}

/// Implement the proxy deserialization trait for the wrapped type.
impl<'a> DeserializeDyn<'a, BottlerocketSettingsProvider> for BottlerocketSettingsInterface {
/// Deserialize the type by way of a serde_json raw value ref.
type Proxy = RawValueRef<'a>;

// Load the plugin, then pass the provided input to the deserialize function via FFI.
fn deserialize_dyn(s: Self::Proxy) -> Result<BottlerocketSettingsProvider, RBoxError> {
BottlerocketSettingsPluginRef::load();
BottlerocketSettingsPluginRef::get_module()
.unwrap()
.deserialize_settings()(s.get_rstr())
.into_result()
}
}

/// Define the boxed wrapper type returned by FFI functions.
pub type BottlerocketSettingsProvider = DynTrait<'static, RBox<()>, BottlerocketSettingsInterface>;

// =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^=

/// The Default trait is already used for `DynTrait`, so add a custom trait to provide the same
/// behavior.
pub trait BottlerocketDefaults: Sized {
fn defaults() -> Self;
}

/// Implement the custom default trait for the boxed wrapper type.
impl BottlerocketDefaults for BottlerocketSettingsProvider {
// Load the plugin, then call the defaults function via FFI.
fn defaults() -> Self {
BottlerocketSettingsPluginRef::load();
BottlerocketSettingsPluginRef::get_module()
.unwrap()
.default_settings()()
}
}

// =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^=

/// Helper function that plugins can use to implement the deserialize function.
/// This runs on the plugin side of the FFI boundary.
pub fn deserialize_json<'a, T>(s: RStr<'a>) -> RResult<T, RBoxError>
where
T: serde::Deserialize<'a>,
{
match serde_json::from_str::<T>(s.into()) {
Ok(x) => ROk(x),
Err(e) => RErr(RBoxError::new(e)),
}
}

/// Helper function that plugins can use to implement the serialize function.
/// This runs on the plugin side of the FFI boundary.
pub fn serialize_json<T>(value: &T) -> Result<RawValueBox, RBoxError>
where
T: serde::Serialize,
{
match serde_json::value::to_raw_value::<T>(value) {
Ok(v) => Ok(v.into()),
Err(e) => Err(RBoxError::new(e)),
}
}

// =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^=

/// BottlerocketSettings is a wrapper type for the provider wrapper type. It's the preferred way
/// for the host program to interact with the plugin. It provides Serialize, Deserialize, and
/// Default impls that handle some of the quirks that arise when dealing directly with the
/// provider.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BottlerocketSettings(BottlerocketSettingsProvider);

/// Serialize impl that goes through an intermediate JSON value so that type data is available to
/// the host program.
impl Serialize for BottlerocketSettings {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
// First serialize the wrapped type to a string, using the plugin's impl.
let json_string = serde_json::to_string(&self.0).map_err(serde::ser::Error::custom)?;

// Now turn the string into a JSON value, which is a type that the host program can
// introspect.
let json_value =
serde_json::from_str::<JsonValue>(&json_string).map_err(serde::ser::Error::custom)?;

// Pass the JSON value into the provided serializer.
json_value.serialize(serializer)
}
}

/// Deserialize impl that goes through an intermediate JSON value so that type data is available to
/// the host program.
impl<'de> Deserialize<'de> for BottlerocketSettings {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
// First deserialize into a JSON value using the provided deserializer, which is a type
// that the host program knows how to construct.
let json_value = JsonValue::deserialize(deserializer)?;

// Now turn the JSON value back into a JSON string to send across the FFI boundary.
let json_string = serde_json::to_string(&json_value).map_err(serde::de::Error::custom)?;

// Deserialize the wrapped type from the JSON string using the plugin's impl.
Ok(Self(
serde_json::from_str::<BottlerocketSettingsProvider>(&json_string)
.map_err(serde::de::Error::custom)?,
))
}
}

/// Default impl that calls the custom defaults trait on the provider wrapper type.
impl Default for BottlerocketSettings {
fn default() -> Self {
let defaults = BottlerocketSettingsProvider::defaults();
Self(defaults)
}
}
10 changes: 8 additions & 2 deletions deny.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,24 @@ allow = [
"BSD-3-Clause",
"BSL-1.0",
# "CC0-1.0",
# "ISC",
"ISC",
"MIT",
# "OpenSSL",
# "Unlicense",
# "Zlib",
"Zlib",
]

exceptions = [
{ name = "generational-arena", allow = ["MPL-2.0"] },
{ name = "unicode-ident", allow = ["MIT", "Apache-2.0", "Unicode-DFS-2016"] },
]

[bans]
# Deny multiple versions or wildcard dependencies.
multiple-versions = "deny"
wildcards = "deny"

skip = [
# abi_stable is using an older version of syn
{ name = "syn", version = "1" },
]

0 comments on commit d51d948

Please sign in to comment.