From b69609b9c37ec1332135a25dc340a46c8caec52e Mon Sep 17 00:00:00 2001 From: daywalker90 <8257956+daywalker90@users.noreply.github.com> Date: Tue, 7 May 2024 18:52:26 +0200 Subject: [PATCH] cln-plugin: Add dynamic configs and a callback for changes Changelog-Added: cln-plugin: Add dynamic configs and a callback for changes --- plugins/examples/cln-plugin-startup.rs | 33 +++++- plugins/src/lib.rs | 135 +++++++++++++++++-------- plugins/src/options.rs | 55 +++++++++- tests/test_cln_rs.py | 7 ++ 4 files changed, 183 insertions(+), 47 deletions(-) diff --git a/plugins/examples/cln-plugin-startup.rs b/plugins/examples/cln-plugin-startup.rs index 81a001773e62..300c7a79017c 100644 --- a/plugins/examples/cln-plugin-startup.rs +++ b/plugins/examples/cln-plugin-startup.rs @@ -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 { + 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 { 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, diff --git a/plugins/src/lib.rs b/plugins/src/lib.rs index 474615468291..6d4b3f2aec90 100644 --- a/plugins/src/lib.rs +++ b/plugins/src/lib.rs @@ -46,9 +46,10 @@ where options: HashMap, option_values: HashMap>, rpcmethods: HashMap>, + setconfig_callback: Option>, subscriptions: HashMap>, // Contains a Subscription if the user subscribed to "*" - wildcard_subscription : Option>, + wildcard_subscription: Option>, notifications: Vec, custommessages: Vec, featurebits: FeatureBits, @@ -72,9 +73,10 @@ where option_values: HashMap>, configuration: Configuration, rpcmethods: HashMap>, + setconfig_callback: Option>, hooks: HashMap>, subscriptions: HashMap>, - wildcard_subscription : Option>, + wildcard_subscription: Option>, #[allow(dead_code)] // unsure why rust thinks this field isn't used notifications: Vec, } @@ -90,11 +92,12 @@ where { plugin: Plugin, rpcmethods: HashMap>, + setconfig_callback: Option>, #[allow(dead_code)] // Unused until we fill in the Hook structs. hooks: HashMap>, subscriptions: HashMap>, - wildcard_subscription : Option> + wildcard_subscription: Option>, } #[derive(Clone)] @@ -106,7 +109,7 @@ where state: S, /// "options" field of "init" message sent by cln options: HashMap, - option_values: HashMap>, + option_values: Arc>>>, /// "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> + 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(mut self, setconfig_callback: C) -> Builder + where + C: Send + Sync + 'static, + C: Fn(Plugin, Request) -> F + 'static, + F: Future + 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 { 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> { 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 ConfiguredPlugin @@ -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(()) } } } diff --git a/plugins/src/options.rs b/plugins/src/options.rs index 241b761d7fef..65839abef258 100644 --- a/plugins/src/options.rs +++ b/plugins/src/options.rs @@ -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: ::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 { &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, }), ), ]; diff --git a/tests/test_cln_rs.py b/tests/test_cln_rs.py index fa4a8196248a..467759ec2d21 100644 --- a/tests/test_cln_rs.py +++ b/tests/test_cln_rs.py @@ -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