diff --git a/contrib/msggen/msggen/schema.json b/contrib/msggen/msggen/schema.json index c3dabf28e48f..cc1b9ee5df71 100644 --- a/contrib/msggen/msggen/schema.json +++ b/contrib/msggen/msggen/schema.json @@ -32987,7 +32987,7 @@ ], "description": [ "Determines what action is taken:", - " - *subcommand* **start** takes a *path* to an executable as argument and starts it as plugin. *path* may be an absolute path or a path relative to the plugins directory (default *~/.lightning/plugins*). If the plugin is already running and the executable (checksum) has changed, the plugin is killed and restarted except if its an important (or builtin) plugin. If the plugin doesn't complete the 'getmanifest' and 'init' handshakes within 60 seconds, the command will timeout and kill the plugin. Additional *options* may be passed to the plugin, but requires all parameters to be passed as keyword=value pairs using the `-k|--keyword` option which is recommended. For example the following command starts the plugin helloworld.py (present in the plugin directory) with the option greeting set to 'A crazy':", + " - *subcommand* **start** takes a *path* to an executable as argument and starts it as plugin. *path* may be an absolute path or a path relative to the plugins directory (default *~/.lightning/plugins*). If the plugin is already running and the executable (checksum) has changed, the plugin is killed and restarted except if its an important (or builtin) plugin. If the plugin doesn't complete the 'getmanifest' and 'init' handshakes within 60 seconds, the command will timeout and kill the plugin. Additional *options* may be passed to the plugin, either as an *options* array of *keyword=value* strings, or as flattened keyword=value parameters using the `-k|--keyword` option which is recommended. For example the following command starts the plugin helloworld.py (present in the plugin directory) with the option greeting set to 'A crazy':", " ```shell", " lightning-cli -k plugin subcommand=start plugin=helloworld.py greeting='A crazy'", " ```", diff --git a/doc/schemas/plugin.json b/doc/schemas/plugin.json index 11e643a24674..a635f4673912 100644 --- a/doc/schemas/plugin.json +++ b/doc/schemas/plugin.json @@ -30,7 +30,7 @@ ], "description": [ "Determines what action is taken:", - " - *subcommand* **start** takes a *path* to an executable as argument and starts it as plugin. *path* may be an absolute path or a path relative to the plugins directory (default *~/.lightning/plugins*). If the plugin is already running and the executable (checksum) has changed, the plugin is killed and restarted except if its an important (or builtin) plugin. If the plugin doesn't complete the 'getmanifest' and 'init' handshakes within 60 seconds, the command will timeout and kill the plugin. Additional *options* may be passed to the plugin, but requires all parameters to be passed as keyword=value pairs using the `-k|--keyword` option which is recommended. For example the following command starts the plugin helloworld.py (present in the plugin directory) with the option greeting set to 'A crazy':", + " - *subcommand* **start** takes a *path* to an executable as argument and starts it as plugin. *path* may be an absolute path or a path relative to the plugins directory (default *~/.lightning/plugins*). If the plugin is already running and the executable (checksum) has changed, the plugin is killed and restarted except if its an important (or builtin) plugin. If the plugin doesn't complete the 'getmanifest' and 'init' handshakes within 60 seconds, the command will timeout and kill the plugin. Additional *options* may be passed to the plugin, either as an *options* array of *keyword=value* strings, or as flattened keyword=value parameters using the `-k|--keyword` option which is recommended. For example the following command starts the plugin helloworld.py (present in the plugin directory) with the option greeting set to 'A crazy':", " ```shell", " lightning-cli -k plugin subcommand=start plugin=helloworld.py greeting='A crazy'", " ```", diff --git a/lightningd/plugin_control.c b/lightningd/plugin_control.c index 89073f9904fa..7f049296e32f 100644 --- a/lightningd/plugin_control.c +++ b/lightningd/plugin_control.c @@ -1,4 +1,5 @@ #include "config.h" +#include #include #include #include @@ -86,6 +87,68 @@ plugin_dynamic_start(struct plugin_command *pcmd, const char *plugin_path, return command_still_pending(pcmd->cmd); } +/* Plugin options can be given either flattened as a JSON object (e.g. via the + * CLI, with "subcommand" and "plugin" keys removed), or as an "options" array + * of "keyword=value" strings. This merges both forms into a single JSON + * object of name/value pairs, suitable for plugin_add_params(). An element + * without an '=' is treated as a boolean flag. */ +static jsmntok_t *plugin_start_params(const tal_t *ctx, const char *buffer, + const jsmntok_t *params, + const jsmntok_t *options, char **parambuf) +{ + size_t i; + const jsmntok_t *t; + char *newbuf = tal_strdup(ctx, "{"); + bool first = true; + + /* Copy any remaining flat params (object form only). */ + if (params->type == JSMN_OBJECT) { + json_for_each_obj(i, t, params) { + if (json_tok_streq(buffer, t, "subcommand") + || json_tok_streq(buffer, t, "plugin") + || json_tok_streq(buffer, t, "options")) + continue; + if (!first) + tal_append_fmt(&newbuf, ","); + first = false; + /* Raw keys and values are already valid JSON. */ + tal_append_fmt(&newbuf, "%.*s:%.*s", + json_tok_full_len(t), + json_tok_full(buffer, t), + json_tok_full_len(t+1), + json_tok_full(buffer, t+1)); + } + } + + /* Add any explicit "options" array entries. */ + if (options) { + json_for_each_arr(i, t, options) { + const char *opt = json_strdup(tmpctx, buffer, t); + const char *eq = strchr(opt, '='); + struct json_escape *esc; + + if (!first) + tal_append_fmt(&newbuf, ","); + first = false; + if (eq) { + esc = json_escape(tmpctx, + take(tal_strndup(tmpctx, opt, eq - opt))); + tal_append_fmt(&newbuf, "\"%s\":", esc->s); + esc = json_escape(tmpctx, eq + 1); + tal_append_fmt(&newbuf, "\"%s\"", esc->s); + } else { + /* Boolean flags are given without a value. */ + esc = json_escape(tmpctx, opt); + tal_append_fmt(&newbuf, "\"%s\":true", esc->s); + } + } + } + + tal_append_fmt(&newbuf, "}"); + *parambuf = newbuf; + return json_parse_simple(ctx, newbuf, strlen(newbuf)); +} + /** * Called when trying to start a plugin directory through RPC, it registers * all contained plugins recursively and then starts them. @@ -235,11 +298,14 @@ static struct command_result *json_plugin_control(struct command *cmd, return plugin_dynamic_stop(cmd, plugin_name); } else if (streq(subcmd, "start")) { const char *plugin_path; + const jsmntok_t *options = NULL; + char *mod_buffer; jsmntok_t *mod_params; if (!param_check(cmd, buffer, params, p_req("subcommand", param_ignore, cmd), p_req("plugin", param_string, &plugin_path), + p_opt("options", param_array, &options), p_opt_any(), NULL)) return command_param_failed(); @@ -247,11 +313,21 @@ static struct command_result *json_plugin_control(struct command *cmd, /* Manually parse any remaining options (only for objects, * since plugin options must be explicitly named!). */ if (params->type == JSMN_ARRAY) { - if (params->size != 2) + if (params->size > 2 && !options) return command_fail(cmd, JSONRPC2_INVALID_PARAMS, "Extra parameters must be in object"); - mod_params = NULL; + if (options) { + mod_params = plugin_start_params(cmd, buffer, params, + options, &mod_buffer); + } else { + mod_buffer = NULL; + mod_params = NULL; + } + } else if (options) { + mod_params = plugin_start_params(cmd, buffer, params, + options, &mod_buffer); } else { + mod_buffer = NULL; mod_params = json_tok_copy(cmd, params); json_tok_remove(&mod_params, mod_params, @@ -271,7 +347,9 @@ static struct command_result *json_plugin_control(struct command *cmd, if (command_check_only(cmd)) return command_check_done(cmd); - return plugin_dynamic_start(pcmd, plugin_path, buffer, mod_params); + return plugin_dynamic_start(pcmd, plugin_path, + mod_buffer ? mod_buffer : buffer, + mod_params); } else if (streq(subcmd, "startdir")) { const char *dir_path; diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 10b97d9d10ec..bb44f5a1cf48 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -3060,6 +3060,21 @@ def test_dynamic_args(node_factory): assert 'greeting' not in l1.rpc.listconfigs()['configs'] +def test_dynamic_args_options_array(node_factory): + """plugin start accepts options as an explicit array, matching the schema.""" + plugin_path = os.path.join(os.getcwd(), 'tests/plugins/dynamic_option.py') + + l1 = node_factory.get_node() + l1.rpc.plugin_start(plugin_path, options=["test-dynamic-config=Test options array"]) + + assert l1.rpc.dynamic_option_report() == {'test-dynamic-config': 'Test options array'} + assert l1.rpc.listconfigs('test-dynamic-config')['configs']['test-dynamic-config']['value_str'] == 'Test options array' + assert l1.rpc.listconfigs('test-dynamic-config')['configs']['test-dynamic-config']['plugin'] == plugin_path + + l1.rpc.plugin_stop(plugin_path) + assert 'test-dynamic-config' not in l1.rpc.listconfigs()['configs'] + + def test_pyln_request_notify(node_factory): """Test that pyln-client plugins can send notifications. """