From 430ba2f6e75ecdf178b87d22a9c69d1ea4f4c192 Mon Sep 17 00:00:00 2001 From: Ivan Pepelnjak Date: Thu, 4 Jun 2026 17:46:51 +0200 Subject: [PATCH 1/3] Implement plugin CLI hooks Like the shell CLI hooks, the plugin CLI hooks are executed at various steps during the lab up/down process. The plugin hooks could be called before or after the CLI command is executed. --- docs/dev/plugins.md | 21 +++++++++++- netsim/cli/_hooks.py | 58 +++++++++++++++++++++++++++++++++ netsim/cli/create.py | 13 +++----- netsim/cli/down.py | 13 ++++---- netsim/cli/external_commands.py | 12 ------- netsim/cli/up.py | 23 ++++++------- 6 files changed, 101 insertions(+), 39 deletions(-) create mode 100644 netsim/cli/_hooks.py diff --git a/docs/dev/plugins.md b/docs/dev/plugins.md index 6cac94441a..3546ac932e 100644 --- a/docs/dev/plugins.md +++ b/docs/dev/plugins.md @@ -13,6 +13,8 @@ The plugin name specifies either a Python file name (without the `.py` extension This is an underdocumented feature. Performing operations beyond simple data transformation might require digging through the source code. Before proceeding, you might want to [open a discussion on *netlab* GitHub repository](https://github.com/ipspace/netlab/discussions). ``` +## Plugin Hooks + Plugins can define well-known functions that are invoked during the [topology transformation process](transform.md), which includes these steps: * execute plugin **init** function @@ -34,9 +36,26 @@ Every plugin function is called with a single *topology* argument: the current t Plugins extending [configuration modules](../modules.md) might have to define additional module attributes. The [module attribute lists](module-attributes.md) must be extended before any module validation code is executed, either with the plugin defaults or in the plugin **init** function. +## Plugin CLI Hooks + +Plugins are usually used in the data transformation process, but could also be used in later stages of configuration file creation and lab management. + +A plugin can add its name to the `defaults.netlab.create.plugin` list to be called at the time the configuration files are created: + +* The `output` hook is called before any output modules are called and before netlab finalizes the [search path lists](change-search-paths). +* The `post_output` hook is called after the output modules have created the configuration files. + +A plugin can also be called during the **[netlab up](netlab-up)**/**[netlab down](netlab-down)** processing: + +* The plugin must add its name to the `defaults.netlab._command_.plugin` list (where _command_ is **up** or **down**) +* Whenever a netlab command calls a [CLI hook](dev-cli-hooks), it calls the plugin pre-shell hook before the CLI command is executed and the plugin post-shell hook after the CLI command has successfully completed[^SC] +* The plugin hook name is created from the CLI hook name and the `pre_shell_` and `post_shell_` prefixes. For example, `pre_shell_pre_start_lab` hook is called before the `pre_start_lab` CLI command is executed, and the `post_shell_pre_start_lab` hook is called after the CLI command has completed. + +[^SC]: The post-shell hook is obviously not called if the CLI command fails. + ## Plugin Metadata -A plugin can specify global variables that are used to influence the plugin behavior or order of execution: +A plugin can specify global variables that are used to influence the plugin's behavior or order of execution: * `_requires`: A list of prerequisite modules and plugins. _netlab_ will abort if any prerequisite plugins are not listed in the **topology.plugin** list, or if any of the prerequisite modules are not used by at least one node. * `_execute_after`: A list of plugins that should execute before the current plugin. For example, the **ebgp.multihop** plugin has to be executed after **ebgp.utils** plugin, and therefore defines `_execute_after = [ 'ebgp.utils' ]` diff --git a/netsim/cli/_hooks.py b/netsim/cli/_hooks.py new file mode 100644 index 0000000000..f14c2428a8 --- /dev/null +++ b/netsim/cli/_hooks.py @@ -0,0 +1,58 @@ +""" +Implement hooks executed by CLI commands + +netlab CLI commands can execute two types of hooks: + +* CLI hooks -- system commands (usually Bash scripts) +* Plugin hooks -- plugin calls executed after the data transformation + has completed. + +The hooks are registered in the netlab[command] system defaults: + +* Plugin hooks are registered in the 'plugin' list. All plugins are + examined for every hook. +* CLI hooks are registered in the +""" +from box import Box + +from ..augment import plugin as a_plugin +from ..utils import log +from .external_commands import run_command + +P_CACHE: dict = {} # Use a cache to optimize plugin loading + +def cli_plugin_hooks(topology: Box, cli_command: str, hook: str) -> None: + """ + Iterate over plugins that registered the comamnd hook + + Note: we have to reload plugins every time the hooks are called + as the original 'Plugin' dictionary was removed as the last step + in the topology transformation process + """ + global P_CACHE + for p_name in topology.defaults.netlab[cli_command].get('plugin',[]): + if p_name in P_CACHE: + p_module = P_CACHE[p_name] + else: + p_module = a_plugin.load_plugin(p_name,topology) + P_CACHE[p_name] = p_module + + if p_module: + a_plugin.execute_plugin_hook(hook,p_module,topology) + +def cli_shell_hooks(settings: Box, cli_command: str, hook: str) -> None: + hook_key = f'netlab.{cli_command}.{hook}' + cmd = settings.get(hook_key,None) + if log.VERBOSE >= 2: + print(f"CLI hook {hook_key}: {cmd}") + if not cmd: + return + if log.VERBOSE: + log.info(f'Running {hook} CLI hook',module=cli_command,more_data=[cmd]) + if not run_command(cmd): + log.fatal(f'CLI hook {hook} returned an error, aborting...',cli_command) + +def run_cli_hooks(topology: Box, cli_command: str, hook: str) -> None: + cli_plugin_hooks(topology,cli_command,'pre_shell_'+hook) + cli_shell_hooks(topology.defaults,cli_command,hook) + cli_plugin_hooks(topology,cli_command,'post_shell_'+hook) diff --git a/netsim/cli/create.py b/netsim/cli/create.py index bdd18a59d0..c999d772ed 100644 --- a/netsim/cli/create.py +++ b/netsim/cli/create.py @@ -18,6 +18,7 @@ from ..outputs import _TopologyOutput from ..utils import log, strings from . import common_parse_args, error_and_exit, lab_status_log, load_topology, topology_parse_args +from ._hooks import cli_plugin_hooks # @@ -179,15 +180,7 @@ def run(cli_args: typing.List[str], if args.devices: args.output.devices = {} - # Iterate over plugins that registered 'output' hook - # We have to reload the plugin as the original 'Plugin' dictionary was removed - # as the last step in the topology transformation process - # - for p_name in topology.defaults.netlab.create.get('plugin',[]): - plugin = augment.plugin.load_plugin(p_name,topology) - if plugin: - augment.plugin.execute_plugin_hook('output',plugin,topology) - + cli_plugin_hooks(topology,'create','output') # Adjust search paths before creating output files. This step cannot be done # earlier in the process because the plugins might create directories that # are used in search paths @@ -204,4 +197,6 @@ def run(cli_args: typing.List[str], else: log.error('Unknown output format %s' % output_format,log.IncorrectValue,'create') + cli_plugin_hooks(topology,'create','post_output') + return topology diff --git a/netsim/cli/down.py b/netsim/cli/down.py index 4185a91f70..d8d28a020e 100644 --- a/netsim/cli/down.py +++ b/netsim/cli/down.py @@ -15,6 +15,7 @@ from .. import providers from ..utils import log, status, strings from . import ( + _hooks, external_commands, fs_cleanup, is_dry_run, @@ -108,11 +109,11 @@ def stop_provider_lab( if sname is not None: exec_command = topology.defaults.providers[pname][sname].stop - external_commands.run_cli_hooks(topology.defaults,'down',f'pre_stop_{p_name}') + _hooks.run_cli_hooks(topology,'down',f'pre_stop_{p_name}') p_module.call('pre_stop_lab',p_topology) external_commands.stop_lab(topology.defaults,p_name,"netlab down",exec_command) p_module.call('post_stop_lab',p_topology) - external_commands.run_cli_hooks(topology.defaults,'down',f'post_stop_{p_name}') + _hooks.run_cli_hooks(topology,'down',f'post_stop_{p_name}') ''' lab_dir_mismatch -- check if the lab instance is running in the current directory @@ -165,7 +166,7 @@ def stop_all(topology: Box, args: argparse.Namespace) -> None: providers.mark_providers(topology) p_module.call('pre_output_transform',topology) - external_commands.run_cli_hooks(topology.defaults,'down','pre_stop_lab') + _hooks.run_cli_hooks(topology,'down','pre_stop_lab') for s_provider in topology[p_provider].providers: lab_status_change(topology,f'stopping {s_provider} provider') try: @@ -183,7 +184,7 @@ def stop_all(topology: Box, args: argparse.Namespace) -> None: except: if not args.force: sys.exit(1) - external_commands.run_cli_hooks(topology.defaults,'down','post_stop_lab') + _hooks.run_cli_hooks(topology,'down','post_stop_lab') def run(cli_args: typing.List[str]) -> None: args = down_parse(cli_args) @@ -208,14 +209,14 @@ def run(cli_args: typing.List[str]) -> None: stop_all(topology,args) if args.cleanup: - external_commands.run_cli_hooks(topology.defaults,'down','pre_cleanup') + _hooks.run_cli_hooks(topology,'down','pre_cleanup') if 'tools' in topology: log.section_header('Cleanup',f'tool configuration','yellow') tool_cleanup(topology,True) log.section_header('Cleanup',f'configuration files','yellow') down_cleanup(topology,True) - external_commands.run_cli_hooks(topology.defaults,'down','post_cleanup') + _hooks.run_cli_hooks(topology,'down','post_cleanup') if not mismatch: status.remove_lab_status(topology) diff --git a/netsim/cli/external_commands.py b/netsim/cli/external_commands.py index de1a5130df..b5313413e1 100644 --- a/netsim/cli/external_commands.py +++ b/netsim/cli/external_commands.py @@ -197,18 +197,6 @@ def start_lab( if not run_command(exec_command): log.fatal(f"{exec_command} failed, aborting...",cli_command) -def run_cli_hooks(settings: Box, cli_command: str, hook: str) -> None: - hook_key = f'netlab.{cli_command}.{hook}' - cmd = settings.get(hook_key,None) - if log.VERBOSE >= 2: - print(f"CLI hook {hook_key}: {cmd}") - if not cmd: - return - if log.VERBOSE: - log.info(f'Running {hook} CLI hook',module=cli_command,more_data=[cmd]) - if not run_command(cmd): - log.fatal(f'CLI hook {hook} returned an error, aborting...',cli_command) - def deploy_configs( command: str = "test", fast: typing.Optional[bool] = False, diff --git a/netsim/cli/up.py b/netsim/cli/up.py index a22f81f1bf..5855ddc697 100644 --- a/netsim/cli/up.py +++ b/netsim/cli/up.py @@ -19,6 +19,7 @@ from ..utils import log, stats, strings from ..utils import status as _status from . import ( + _hooks, common_parse_args, create, external_commands, @@ -209,7 +210,7 @@ def start_provider_lab(topology: Box, pname: str, sname: typing.Optional[str] = status_start_provider(topology,p_name) defaults = topology.defaults - external_commands.run_cli_hooks(defaults,'up',f'pre_start_{p_name}') + _hooks.run_cli_hooks(topology,'up',f'pre_start_{p_name}') p_module.call('pre_start_lab',p_topology) if sname is not None: @@ -224,7 +225,7 @@ def start_provider_lab(topology: Box, pname: str, sname: typing.Optional[str] = log.fatal(f"{cmd} failed, aborting...","netlab up") p_module.call('post_start_lab',p_topology) - external_commands.run_cli_hooks(defaults,'up',f'post_start_{p_name}') + _hooks.run_cli_hooks(topology,'up',f'post_start_{p_name}') lab_status_change(topology,f'{p_name} workload started') @@ -254,9 +255,9 @@ def deploy_initial_config(args: argparse.Namespace, topology: Box) -> None: lab_status_change(topology,f'deploying initial configuration') log.section_header('Deploying','initial device configurations') - external_commands.run_cli_hooks(topology.defaults,'up','pre_initial_config') + _hooks.run_cli_hooks(topology,'up','pre_initial_config') external_commands.deploy_configs("netlab up",args.fast_config,deploy_only=not args.snapshot) - external_commands.run_cli_hooks(topology.defaults,'up','post_initial_config') + _hooks.run_cli_hooks(topology,'up','post_initial_config') lab_status_change(topology,f'initial configuration complete') message = get_message(topology,'initial',True) @@ -269,11 +270,11 @@ def deploy_initial_config(args: argparse.Namespace, topology: Box) -> None: def reload_saved_config(args: argparse.Namespace, topology: Box) -> None: lab_status_change(topology,f'reloading saved initial configurations') log.section_header('Reloading','saved initial device configurations') - external_commands.run_cli_hooks(topology.defaults,'up','pre_reload_config') + _hooks.run_cli_hooks(topology,'up','pre_reload_config') cmd = external_commands.set_ansible_flags(['netlab','config','--reload',args.reload]) if not external_commands.run_command(cmd): log.fatal("netlab config --reload failed, aborting...",'netlab up') - external_commands.run_cli_hooks(topology.defaults,'up','post_reload_config') + _hooks.run_cli_hooks(topology,'up','post_reload_config') lab_status_change(topology,f'saved initial configurations reloaded') log.status_success() print("Saved configurations reloaded",flush=True) @@ -309,7 +310,7 @@ def start_external_tools(args: argparse.Namespace, topology: Box) -> None: return lab_status_change(topology,f'starting external tools') - external_commands.run_cli_hooks(topology.defaults,'up','pre_tools_start') + _hooks.run_cli_hooks(topology,'up','pre_tools_start') log.section_header('Starting','external tools') t_count = 0 t_success = 0 @@ -335,7 +336,7 @@ def start_external_tools(args: argparse.Namespace, topology: Box) -> None: else: print(f"{msg}\n",flush=True) - external_commands.run_cli_hooks(topology.defaults,'up','post_tools_start') + _hooks.run_cli_hooks(topology,'up','post_tools_start') lab_status_change(topology,f'{t_success}/{t_count} external tools started') if not is_dry_run(): log.partial_success(t_success,t_count) @@ -364,7 +365,7 @@ def run_up(cli_args: typing.List[str]) -> None: os.environ["ANSIBLE_STDOUT_CALLBACK"] = "selective" external_commands.LOG_COMMANDS = True - external_commands.run_cli_hooks(topology.defaults,'up','pre_probe') + _hooks.run_cli_hooks(topology,'up','pre_probe') provider_probes(topology) if not args.no_config: process_config_sw_check(topology) @@ -384,7 +385,7 @@ def run_up(cli_args: typing.List[str]) -> None: _status.lock_directory() log.section_header('Starting',f'{p_provider} nodes') - external_commands.run_cli_hooks(topology.defaults,'up',f'pre_start_lab') + _hooks.run_cli_hooks(topology,'up',f'pre_start_lab') start_provider_lab(topology,p_provider) for s_provider in topology[p_provider].providers: @@ -395,7 +396,7 @@ def run_up(cli_args: typing.List[str]) -> None: if topology.get('defaults.tc.enable',True): providers.execute_tc_commands(topology) - external_commands.run_cli_hooks(topology.defaults,'up',f'post_start_lab') + _hooks.run_cli_hooks(topology,'up',f'post_start_lab') try: if args.reload: reload_saved_config(args,topology) From 67dacd3370ac862a5d3a9ebe39d91f526c7a8075 Mon Sep 17 00:00:00 2001 From: Ivan Pepelnjak Date: Thu, 4 Jun 2026 18:02:19 +0200 Subject: [PATCH 2/3] Fixing the nits --- netsim/cli/_hooks.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/netsim/cli/_hooks.py b/netsim/cli/_hooks.py index f14c2428a8..226ad5b0b2 100644 --- a/netsim/cli/_hooks.py +++ b/netsim/cli/_hooks.py @@ -11,7 +11,8 @@ * Plugin hooks are registered in the 'plugin' list. All plugins are examined for every hook. -* CLI hooks are registered in the +* CLI hooks are registered in the _hook_ string. A single CLI command + can be executed for every hook. """ from box import Box @@ -25,20 +26,22 @@ def cli_plugin_hooks(topology: Box, cli_command: str, hook: str) -> None: """ Iterate over plugins that registered the comamnd hook - Note: we have to reload plugins every time the hooks are called - as the original 'Plugin' dictionary was removed as the last step - in the topology transformation process + Note: we're caching the loaded plugins to avoid repeated attempts + to load the same plugins. We cannot use the original 'Plugin' + dictionary as it's removed as the last step in the topology + transformation process """ global P_CACHE - for p_name in topology.defaults.netlab[cli_command].get('plugin',[]): - if p_name in P_CACHE: - p_module = P_CACHE[p_name] + p_list = topology.defaults.get(f'netlab.{cli_command}.plugin',[]) + for p_name in p_list: + if p_name in P_CACHE: # Have we tried to load the plugin before? + p_module = P_CACHE[p_name] # Use the previous result else: - p_module = a_plugin.load_plugin(p_name,topology) - P_CACHE[p_name] = p_module + p_module = a_plugin.load_plugin(p_name,topology) # Try to load the plugin + P_CACHE[p_name] = p_module # And cache whatever we got (including the failure) - if p_module: - a_plugin.execute_plugin_hook(hook,p_module,topology) + if p_module: # Did we succeed in loading the plugin? + a_plugin.execute_plugin_hook(hook,p_module,topology) # Try to execute the relevant plugin hook def cli_shell_hooks(settings: Box, cli_command: str, hook: str) -> None: hook_key = f'netlab.{cli_command}.{hook}' From eef34eacedf14373a7ff46381cd3de7c098b409b Mon Sep 17 00:00:00 2001 From: Ivan Pepelnjak Date: Fri, 5 Jun 2026 10:41:53 +0200 Subject: [PATCH 3/3] Final touches: plugin hook logging, platform integration test --- netsim/cli/_hooks.py | 7 ++++++ .../platform-integration/cli/01-up-hooks.yml | 22 ++++++++----------- tests/platform-integration/cli/up_hook.py | 17 ++++++++++++++ 3 files changed, 33 insertions(+), 13 deletions(-) create mode 100644 tests/platform-integration/cli/up_hook.py diff --git a/netsim/cli/_hooks.py b/netsim/cli/_hooks.py index 226ad5b0b2..7ac4382d22 100644 --- a/netsim/cli/_hooks.py +++ b/netsim/cli/_hooks.py @@ -33,12 +33,19 @@ def cli_plugin_hooks(topology: Box, cli_command: str, hook: str) -> None: """ global P_CACHE p_list = topology.defaults.get(f'netlab.{cli_command}.plugin',[]) + if log.VERBOSE >= 3: + print(f"CLI command {cli_command} plugin hooks: {p_list} hook: {hook}") for p_name in p_list: if p_name in P_CACHE: # Have we tried to load the plugin before? p_module = P_CACHE[p_name] # Use the previous result else: p_module = a_plugin.load_plugin(p_name,topology) # Try to load the plugin P_CACHE[p_name] = p_module # And cache whatever we got (including the failure) + if log.VERBOSE >= 3: + if p_module: + print(f"Loaded CLI hook plugin {p_name}") + else: + print(f"Failed to load CLI hook plugin {p_name}") if p_module: # Did we succeed in loading the plugin? a_plugin.execute_plugin_hook(hook,p_module,topology) # Try to execute the relevant plugin hook diff --git a/tests/platform-integration/cli/01-up-hooks.yml b/tests/platform-integration/cli/01-up-hooks.yml index b980a5057e..73a61ee8b9 100644 --- a/tests/platform-integration/cli/01-up-hooks.yml +++ b/tests/platform-integration/cli/01-up-hooks.yml @@ -2,7 +2,7 @@ message: The test uses "netlab up" CLI hooks to create extra files. The validation script checks for the presence of those files. -plugin: [ files ] +plugin: [ files, up_hook ] provider: clab defaults.device: linux @@ -23,15 +23,11 @@ files: echo "topology=$NETLAB_ARGS_TOPOLOGY" >pre_start.hook validate.sh: | set -e - if [ -f pre_start.hook ]; then - echo "Pre-start hook was executed" - else - echo "No artifact of the pre-start hook" - exit 1 - fi - if [ -f post_start.hook ]; then - echo "Post-start hook was executed" - else - echo "No artifact of the post-start hook" - exit 1 - fi + for hook in pre_probe pre_start post_start; do + if [ -f "${hook}.hook" ]; then + echo "${hook} hook was executed" + else + echo "No artifact of the ${hook} hook" + exit 1 + fi + done diff --git a/tests/platform-integration/cli/up_hook.py b/tests/platform-integration/cli/up_hook.py new file mode 100644 index 0000000000..a6b08cd6a0 --- /dev/null +++ b/tests/platform-integration/cli/up_hook.py @@ -0,0 +1,17 @@ +# +# Plugin used to test "netlab up" hook +# + +import pathlib + +from box import Box + +from netsim.data import append_to_list + + +def init(topology: Box) -> None: + append_to_list(topology.defaults.netlab.up,'plugin','up_hook') + +def pre_shell_pre_probe(topology: Box) -> None: + with pathlib.Path("pre_probe.hook").open("w") as f: + f.write('Hello, handsome ;)')