Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion docs/dev/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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' ]`
Expand Down
68 changes: 68 additions & 0 deletions netsim/cli/_hooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""
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 _hook_ string. A single CLI command
can be executed for every hook.
"""
Comment thread
ipspace marked this conversation as resolved.
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'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
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

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)
13 changes: 4 additions & 9 deletions netsim/cli/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


#
Expand Down Expand Up @@ -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
Expand All @@ -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
13 changes: 7 additions & 6 deletions netsim/cli/down.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from .. import providers
from ..utils import log, status, strings
from . import (
_hooks,
external_commands,
fs_cleanup,
is_dry_run,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand All @@ -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)
Expand Down
12 changes: 0 additions & 12 deletions netsim/cli/external_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
23 changes: 12 additions & 11 deletions netsim/cli/up.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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')

Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand All @@ -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)
Expand Down
22 changes: 9 additions & 13 deletions tests/platform-integration/cli/01-up-hooks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
17 changes: 17 additions & 0 deletions tests/platform-integration/cli/up_hook.py
Original file line number Diff line number Diff line change
@@ -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 ;)')