cln-plugin: Add dynamic configs and a callback for changes

Changelog-Added: cln-plugin: Add dynamic configs and a callback for changes
This commit is contained in:
daywalker90 2024-05-07 18:52:26 +02:00 committed by Christian Decker
parent dab9605e1f
commit b69609b9c3
4 changed files with 182 additions and 46 deletions

View file

@ -2,7 +2,9 @@
//! plugins using the Rust API against Core Lightning.
#[macro_use]
extern crate serde_json;
use cln_plugin::options::{DefaultIntegerConfigOption, IntegerConfigOption};
use cln_plugin::options::{
self, BooleanConfigOption, DefaultIntegerConfigOption, IntegerConfigOption,
};
use cln_plugin::{messages, Builder, Error, Plugin};
use tokio;
@ -21,9 +23,17 @@ const TEST_OPTION_NO_DEFAULT: IntegerConfigOption =
async fn main() -> Result<(), anyhow::Error> {
let state = ();
let test_dynamic_option: BooleanConfigOption = BooleanConfigOption::new_bool_no_default(
"test-dynamic-option",
"A option that can be changed dynamically",
)
.dynamic();
if let Some(plugin) = Builder::new(tokio::io::stdin(), tokio::io::stdout())
.option(TEST_OPTION)
.option(TEST_OPTION_NO_DEFAULT)
.option(test_dynamic_option)
.setconfig_callback(setconfig_callback)
.rpcmethod("testmethod", "This is a test", testmethod)
.rpcmethod(
"testoptions",
@ -48,10 +58,27 @@ async fn main() -> Result<(), anyhow::Error> {
}
}
async fn setconfig_callback(
plugin: Plugin<()>,
args: serde_json::Value,
) -> Result<serde_json::Value, Error> {
let name = args.get("config").unwrap().as_str().unwrap();
let value = args.get("val").unwrap();
let opt_value = options::Value::String(value.to_string());
plugin.set_option_str(name, opt_value)?;
log::info!(
"cln-plugin-startup: Got dynamic option change: {} {}",
name,
plugin.option_str(name).unwrap().unwrap().as_str().unwrap()
);
Ok(json!({}))
}
async fn testoptions(p: Plugin<()>, _v: serde_json::Value) -> Result<serde_json::Value, Error> {
let test_option = p.option(&TEST_OPTION)?;
let test_option_no_default = p
.option(&TEST_OPTION_NO_DEFAULT)?;
let test_option_no_default = p.option(&TEST_OPTION_NO_DEFAULT)?;
Ok(json!({
"test-option": test_option,

View file

@ -46,9 +46,10 @@ where
options: HashMap<String, UntypedConfigOption>,
option_values: HashMap<String, Option<options::Value>>,
rpcmethods: HashMap<String, RpcMethod<S>>,
setconfig_callback: Option<AsyncCallback<S>>,
subscriptions: HashMap<String, Subscription<S>>,
// Contains a Subscription if the user subscribed to "*"
wildcard_subscription : Option<Subscription<S>>,
wildcard_subscription: Option<Subscription<S>>,
notifications: Vec<NotificationTopic>,
custommessages: Vec<u16>,
featurebits: FeatureBits,
@ -72,9 +73,10 @@ where
option_values: HashMap<String, Option<options::Value>>,
configuration: Configuration,
rpcmethods: HashMap<String, AsyncCallback<S>>,
setconfig_callback: Option<AsyncCallback<S>>,
hooks: HashMap<String, AsyncCallback<S>>,
subscriptions: HashMap<String, AsyncNotificationCallback<S>>,
wildcard_subscription : Option<AsyncNotificationCallback<S>>,
wildcard_subscription: Option<AsyncNotificationCallback<S>>,
#[allow(dead_code)] // unsure why rust thinks this field isn't used
notifications: Vec<NotificationTopic>,
}
@ -90,11 +92,12 @@ where
{
plugin: Plugin<S>,
rpcmethods: HashMap<String, AsyncCallback<S>>,
setconfig_callback: Option<AsyncCallback<S>>,
#[allow(dead_code)] // Unused until we fill in the Hook structs.
hooks: HashMap<String, AsyncCallback<S>>,
subscriptions: HashMap<String, AsyncNotificationCallback<S>>,
wildcard_subscription : Option<AsyncNotificationCallback<S>>
wildcard_subscription: Option<AsyncNotificationCallback<S>>,
}
#[derive(Clone)]
@ -106,7 +109,7 @@ where
state: S,
/// "options" field of "init" message sent by cln
options: HashMap<String, UntypedConfigOption>,
option_values: HashMap<String, Option<options::Value>>,
option_values: Arc<std::sync::Mutex<HashMap<String, Option<options::Value>>>>,
/// "configuration" field of "init" message sent by cln
configuration: Configuration,
/// A signal that allows us to wait on the plugin's shutdown.
@ -133,6 +136,7 @@ where
// This values are set when parsing the init-call
option_values: HashMap::new(),
rpcmethods: HashMap::new(),
setconfig_callback: None,
notifications: vec![],
featurebits: FeatureBits::default(),
dynamic: false,
@ -179,13 +183,12 @@ where
F: Future<Output = Result<(), Error>> + Send + 'static,
{
let subscription = Subscription {
callback : Box::new(move |p, r| Box::pin(callback(p, r)))
callback: Box::new(move |p, r| Box::pin(callback(p, r))),
};
if topic == "*" {
self.wildcard_subscription = Some(subscription);
}
else {
} else {
self.subscriptions.insert(topic.to_string(), subscription);
};
self
@ -233,6 +236,17 @@ where
self
}
/// Register a callback for setconfig to accept changes for dynamic options
pub fn setconfig_callback<C, F>(mut self, setconfig_callback: C) -> Builder<S, I, O>
where
C: Send + Sync + 'static,
C: Fn(Plugin<S>, Request) -> F + 'static,
F: Future<Output = Response> + Send + 'static,
{
self.setconfig_callback = Some(Box::new(move |p, r| Box::pin(setconfig_callback(p, r))));
self
}
/// Send true value for "dynamic" field in "getmanifest" response
pub fn dynamic(mut self) -> Builder<S, I, O> {
self.dynamic = true;
@ -337,7 +351,7 @@ where
let subscriptions =
HashMap::from_iter(self.subscriptions.drain().map(|(k, v)| (k, v.callback)));
let all_subscription = self.wildcard_subscription.map(|s| s.callback);
let all_subscription = self.wildcard_subscription.map(|s| s.callback);
// Leave the `init` reply pending, so we can disable based on
// the options if required.
@ -347,9 +361,10 @@ where
input,
output,
rpcmethods,
setconfig_callback: self.setconfig_callback,
notifications: self.notifications,
subscriptions,
wildcard_subscription: all_subscription,
wildcard_subscription: all_subscription,
options: self.options,
option_values: self.option_values,
configuration,
@ -389,9 +404,12 @@ where
})
.collect();
let subscriptions = self.subscriptions.keys()
let subscriptions = self
.subscriptions
.keys()
.map(|s| s.clone())
.chain(self.wildcard_subscription.iter().map(|_| String::from("*"))).collect();
.chain(self.wildcard_subscription.iter().map(|_| String::from("*")))
.collect();
messages::GetManifestResponse {
options: self.options.values().cloned().collect(),
@ -524,9 +542,11 @@ where
{
pub fn option_str(&self, name: &str) -> Result<Option<options::Value>> {
self.option_values
.lock()
.unwrap()
.get(name)
.ok_or(anyhow!("No option named {}", name))
.map(|c| c.clone())
.cloned()
}
pub fn option<'a, OV: OptionType<'a>>(
@ -536,6 +556,25 @@ where
let value = self.option_str(config_option.name())?;
Ok(OV::from_value(&value))
}
pub fn set_option_str(&self, name: &str, value: options::Value) -> Result<()> {
*self
.option_values
.lock()
.unwrap()
.get_mut(name)
.ok_or(anyhow!("No option named {}", name))? = Some(value);
Ok(())
}
pub fn set_option<'a, OV: OptionType<'a>>(
&self,
config_option: &options::ConfigOption<'a, OV>,
value: options::Value,
) -> Result<()> {
self.set_option_str(config_option.name(), value)?;
Ok(())
}
}
impl<S, I, O> ConfiguredPlugin<S, I, O>
@ -557,7 +596,7 @@ where
let plugin = Plugin {
state,
options: self.options,
option_values: self.option_values,
option_values: Arc::new(std::sync::Mutex::new(self.option_values)),
configuration: self.configuration,
wait_handle,
sender,
@ -566,9 +605,10 @@ where
let driver = PluginDriver {
plugin: plugin.clone(),
rpcmethods: self.rpcmethods,
setconfig_callback: self.setconfig_callback,
hooks: self.hooks,
subscriptions: self.subscriptions,
wildcard_subscription : self.wildcard_subscription
wildcard_subscription: self.wildcard_subscription,
};
output
@ -704,9 +744,16 @@ where
.context("Missing 'method' in request")?
.as_str()
.context("'method' is not a string")?;
let callback = self.rpcmethods.get(method).with_context(|| {
anyhow!("No handler for method '{}' registered", method)
})?;
let callback = match method {
name if name.eq("setconfig") => {
self.setconfig_callback.as_ref().ok_or_else(|| {
anyhow!("No handler for method '{}' registered", method)
})?
}
_ => self.rpcmethods.get(method).with_context(|| {
anyhow!("No handler for method '{}' registered", method)
})?,
};
let params = request
.get("params")
.context("Missing 'params' field in request")?
@ -740,7 +787,7 @@ where
Ok(())
}
messages::JsonRpc::CustomNotification(request) => {
// This code handles notifications
// This code handles notifications
trace!("Dispatching custom notification {:?}", request);
let method = request
.get("method")
@ -751,30 +798,34 @@ where
let params = request
.get("params")
.context("Missing 'params' field in request")?;
// Send to notification to the wildcard
// subscription "*" it it exists
match &self.wildcard_subscription {
Some(cb) => {
let call = cb(plugin.clone(), params.clone());
tokio::spawn(async move {call.await.unwrap()});}
None => {}
};
// Find the appropriate callback and process it
// We'll log a warning if no handler is defined
match self.subscriptions.get(method) {
Some(cb) => {
let call = cb(plugin.clone(), params.clone());
tokio::spawn(async move {call.await.unwrap()});
},
None => {
if self.wildcard_subscription.is_none() {
log::warn!("No handler for notification '{}' registered", method);
}
}
};
Ok(())
// Send to notification to the wildcard
// subscription "*" it it exists
match &self.wildcard_subscription {
Some(cb) => {
let call = cb(plugin.clone(), params.clone());
tokio::spawn(async move { call.await.unwrap() });
}
None => {}
};
// Find the appropriate callback and process it
// We'll log a warning if no handler is defined
match self.subscriptions.get(method) {
Some(cb) => {
let call = cb(plugin.clone(), params.clone());
tokio::spawn(async move { call.await.unwrap() });
}
None => {
if self.wildcard_subscription.is_none() {
log::warn!(
"No handler for notification '{}' registered",
method
);
}
}
};
Ok(())
}
}
}

View file

@ -76,6 +76,7 @@
//! default : (), // We provide no default here
//! description : "A config option of type string that takes no default",
//! deprecated : false, // Option is not deprecated
//! dynamic: false, //Option is not dynamic
//! };
//! ```
//!
@ -420,6 +421,7 @@ pub struct ConfigOption<'a, V: OptionType<'a>> {
pub default: V::DefaultValue,
pub description: &'a str,
pub deprecated: bool,
pub dynamic: bool,
}
impl<'a, V: OptionType<'a>> ConfigOption<'a, V> {
@ -430,6 +432,7 @@ impl<'a, V: OptionType<'a>> ConfigOption<'a, V> {
default: <V as OptionType>::convert_default(&self.default),
description: self.description.to_string(),
deprecated: self.deprecated,
dynamic: self.dynamic,
}
}
}
@ -445,8 +448,13 @@ impl<'a> DefaultStringConfigOption<'a> {
default: default,
description: description,
deprecated: false,
dynamic: false,
}
}
pub fn dynamic(mut self) -> Self {
self.dynamic = true;
self
}
}
impl<'a> StringConfigOption<'a> {
@ -456,8 +464,13 @@ impl<'a> StringConfigOption<'a> {
default: (),
description: description,
deprecated: false,
dynamic: false,
}
}
pub fn dynamic(mut self) -> Self {
self.dynamic = true;
self
}
}
impl<'a> DefaultIntegerConfigOption<'a> {
@ -467,8 +480,13 @@ impl<'a> DefaultIntegerConfigOption<'a> {
default: default,
description: description,
deprecated: false,
dynamic: false,
}
}
pub fn dynamic(mut self) -> Self {
self.dynamic = true;
self
}
}
impl<'a> IntegerConfigOption<'a> {
@ -478,8 +496,13 @@ impl<'a> IntegerConfigOption<'a> {
default: (),
description: description,
deprecated: false,
dynamic: false,
}
}
pub fn dynamic(mut self) -> Self {
self.dynamic = true;
self
}
}
impl<'a> BooleanConfigOption<'a> {
@ -489,8 +512,13 @@ impl<'a> BooleanConfigOption<'a> {
description,
default: (),
deprecated: false,
dynamic: false,
}
}
pub fn dynamic(mut self) -> Self {
self.dynamic = true;
self
}
}
impl<'a> DefaultBooleanConfigOption<'a> {
@ -500,8 +528,13 @@ impl<'a> DefaultBooleanConfigOption<'a> {
description,
default: default,
deprecated: false,
dynamic: false,
}
}
pub fn dynamic(mut self) -> Self {
self.dynamic = true;
self
}
}
impl<'a> FlagConfigOption<'a> {
@ -511,8 +544,13 @@ impl<'a> FlagConfigOption<'a> {
description,
default: (),
deprecated: false,
dynamic: false,
}
}
pub fn dynamic(mut self) -> Self {
self.dynamic = true;
self
}
}
fn is_false(b: &bool) -> bool {
@ -530,6 +568,7 @@ pub struct UntypedConfigOption {
description: String,
#[serde(skip_serializing_if = "is_false")]
deprecated: bool,
dynamic: bool,
}
impl UntypedConfigOption {
@ -539,6 +578,10 @@ impl UntypedConfigOption {
pub fn default(&self) -> &Option<Value> {
&self.default
}
pub fn dynamic(mut self) -> Self {
self.dynamic = true;
self
}
}
impl<'a, V> ConfigOption<'a, V>
@ -569,6 +612,7 @@ mod test {
"description":"description",
"default": "default",
"type": "string",
"dynamic": false,
}),
),
(
@ -578,15 +622,21 @@ mod test {
"description":"description",
"default": 42,
"type": "int",
"dynamic": false,
}),
),
(
ConfigOption::new_bool_with_default("name", true, "description").build(),
{
ConfigOption::new_bool_with_default("name", true, "description")
.build()
.dynamic()
},
json!({
"name": "name",
"description":"description",
"default": true,
"type": "bool",
"dynamic": true,
}),
),
(
@ -595,7 +645,8 @@ mod test {
"name" : "name",
"description": "description",
"type" : "flag",
"default" : false
"default" : false,
"dynamic": false,
}),
),
];

View file

@ -66,6 +66,13 @@ def test_plugin_start(node_factory):
l1.daemon.wait_for_log(r'Got a connect hook call')
l1.daemon.wait_for_log(r'Got a connect notification')
l1.rpc.setconfig("test-dynamic-option", True)
assert l1.rpc.listconfigs("test-dynamic-option")["configs"]["test-dynamic-option"]["value_bool"]
wait_for(lambda: l1.daemon.is_in_log(r'cln-plugin-startup: Got dynamic option change: test-dynamic-option \\"true\\"'))
l1.rpc.setconfig("test-dynamic-option", False)
assert not l1.rpc.listconfigs("test-dynamic-option")["configs"]["test-dynamic-option"]["value_bool"]
wait_for(lambda: l1.daemon.is_in_log(r'cln-plugin-startup: Got dynamic option change: test-dynamic-option \\"false\\"'))
def test_plugin_options_handle_defaults(node_factory):
"""Start a minimal plugin and ensure it is well-behaved