From 5d8634cc7253f69afd0481ad761919f9cbdb3b53 Mon Sep 17 00:00:00 2001 From: Muddyblack <70057554+Muddyblack@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:37:13 +0200 Subject: [PATCH 1/3] Add 'netlab node' to start, stop, restart or pause single nodes The new command acts on individual containerlab nodes without touching the rest of the lab: up/down/restart use 'containerlab start|stop|restart --node' (containerlab 0.75+), pause/unpause use Docker. 'netlab status' now runs 'docker ps -a' so stopped nodes are listed as 'Exited' instead of disappearing. --- docs/netlab/cli.md | 2 ++ docs/netlab/node.md | 57 +++++++++++++++++++++++++++++++ docs/netlab/status.md | 2 +- netsim/cli/node.py | 54 +++++++++++++++++++++++++++++ netsim/cli/usage.txt | 2 ++ netsim/providers/clab/__init__.py | 13 ++++++- netsim/providers/clab/labops.py | 2 +- 7 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 docs/netlab/node.md create mode 100644 netsim/cli/node.py diff --git a/docs/netlab/cli.md b/docs/netlab/cli.md index f94bad220e..d05faa06f5 100644 --- a/docs/netlab/cli.md +++ b/docs/netlab/cli.md @@ -25,6 +25,7 @@ The **netlab** command is the *netlab* CLI interface. It includes data model tra * **‌[netlab validate](validate.md)** executes tests defined in the lab topology on the lab devices * **[netlab down](down.md)** destroys the virtual lab. * **[netlab exec](exec.md)** executes a command on one or more network devices. +* **[netlab node](node.md)** starts, stops, or pauses individual *containerlab* nodes. * **[netlab api](api.md)** starts a lightweight HTTP API server for automation. ## Reports and Graphs @@ -74,6 +75,7 @@ The **netlab** command is the *netlab* CLI interface. It includes data model tra netlab inspect netlab install netlab libvirt + netlab node netlab report netlab restart netlab show diff --git a/docs/netlab/node.md b/docs/netlab/node.md new file mode 100644 index 0000000000..a1e70e3b16 --- /dev/null +++ b/docs/netlab/node.md @@ -0,0 +1,57 @@ +(netlab-node)= +# Start, Stop, or Pause Individual Lab Nodes + +The **netlab node** command starts, stops, restarts, pauses, or unpauses individual nodes in a running lab without affecting the rest of the lab. + +```{warning} +* **netlab node** works only with *containerlab* nodes. +* A restarted container loses the device configuration deployed by _netlab_. Use **netlab initial --limit _node_** to configure it again. +``` + +## Usage + +```text +usage: netlab node [-h] [-v] [-q] [--dry-run] [-i INSTANCE] node {up,down,restart,pause,unpause} + +Start, stop, restart, pause, or unpause individual lab nodes + +positional arguments: + node Node(s) to act on (node names, groups, or globs, separated by commas) + {up,down,restart,pause,unpause} + Action to execute on the selected nodes + +options: + -h, --help show this help message and exit + -v, --verbose Verbose logging (add multiple flags for increased verbosity) + -q, --quiet Report only major errors + --dry-run Print the commands that would be executed, but do not execute them + -i INSTANCE, --instance INSTANCE + Specify the lab instance to change nodes in +``` + +The **netlab node** command takes two parameters: + +* A [list of nodes](netlab-inspect-node): node names, group names, or globs, separated by commas. +* The action to execute: + * **down** stops the node container (`containerlab stop`) + * **up** starts a stopped node container and reconnects its links (`containerlab start`) + * **restart** stops and starts the node container (`containerlab restart`) + * **pause** freezes all processes in the node container (`docker pause`) + * **unpause** resumes a paused node container (`docker unpause`) + +Notes: + +* **netlab node** reads the transformed lab topology from the `netlab.snapshot.pickle` file created by **netlab up** or **netlab create**. +* With the `--instance` flag, you can change nodes in a lab instance running in a different directory. +* **[netlab status](status.md)** displays the state of stopped and paused containers. +* Some devices reload their saved configuration (for example, FRR after **write memory**) when restarted, but that does not restore VLANs, VRFs, LAGs, or VXLAN. +* Routing protocol sessions with a paused node might time out. + +## Examples + +```bash +$ netlab node r1 down +$ netlab node r1 up +$ netlab initial --limit r1 +$ netlab node r1,r2 pause +``` diff --git a/docs/netlab/status.md b/docs/netlab/status.md index a9867110a0..504343a998 100644 --- a/docs/netlab/status.md +++ b/docs/netlab/status.md @@ -89,7 +89,7 @@ Lab default in /home/user (netlab-status-provider)= ```{tip} -* **‌netlab status** executes **‌vagrant status --machine-readable** to get the status of Vagrant-controlled virtual machines and **‌docker ps** to get the status of running containers. The **vagrant status‌** might take a few seconds when executed on large labs and significantly longer if Vagrant cannot determine the state of a virtual machine (returning **‌inaccessible**). +* **‌netlab status** executes **‌vagrant status --machine-readable** to get the status of Vagrant-controlled virtual machines and **‌docker ps -a** to get the status of containers (including the [stopped ones](netlab-node)). The **vagrant status‌** might take a few seconds when executed on large labs and significantly longer if Vagrant cannot determine the state of a virtual machine (returning **‌inaccessible**). * **‌netlab status --memory** executes **‌virsh domstats** and **‌docker stats**. At least the **‌docker stats** command is not blazingly fast. * The **‌virsh domstats** reports the Resident Set Size (RSS) of the QEMU process running the virtual machine (the physical RAM allocation), not the amount of virtual RAM allocated to the virtual machine. * With Linux Kernel Samepage Merging (KSM) enabled, the reported memory utilization overstates the real usage. diff --git a/netsim/cli/node.py b/netsim/cli/node.py new file mode 100644 index 0000000000..9b031e0f2f --- /dev/null +++ b/netsim/cli/node.py @@ -0,0 +1,54 @@ +# +# netlab node command +# +# Start, stop, restart, pause, or unpause individual lab nodes without touching the rest of the lab +# +import argparse +import typing + +from ..providers import execute_node +from ..utils import log +from . import _nodeset, load_snapshot, parser_add_verbose, parser_lab_location, set_dry_run + + +# +# CLI parser for 'netlab node' command +# +def node_parse(args: typing.List[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="netlab node", + description='Start, stop, restart, pause, or unpause individual lab nodes') + parser_add_verbose(parser,quiet=True) + parser.add_argument( + '--dry-run', + dest='dry_run', + action='store_true', + help='Print the commands that would be executed, but do not execute them') + parser.add_argument( + dest='node', action='store', + help='Node(s) to act on (node names, groups, or globs, separated by commas)') + parser.add_argument( + dest='action', action='store', + choices=['up','down','restart','pause','unpause'], + help='Action to execute on the selected nodes') + parser_lab_location(parser,instance=True,action='change nodes in') + + return parser.parse_args(args) + +def run(cli_args: typing.List[str]) -> None: + args = node_parse(cli_args) + log.set_logging_flags(args) + set_dry_run(args) + + topology = load_snapshot(args) + node_list = _nodeset.parse_nodeset(args.node,topology) + log.exit_on_error() + + for node in node_list: + status = execute_node('control_node',node=topology.nodes[node],topology=topology,action=args.action) + if status is None: + log.error(f'The virtualization provider of node {node} does not support "netlab node"',module='node',skip_header=True) + elif not status: + log.error(f'Failed to {args.action} node {node}',module='node',skip_header=True) + + log.exit_on_error() diff --git a/netsim/cli/usage.txt b/netsim/cli/usage.txt index 5d8fa7acb3..3abfcda914 100644 --- a/netsim/cli/usage.txt +++ b/netsim/cli/usage.txt @@ -29,6 +29,8 @@ down Destroy the virtual lab exec Executes a command on one or more network devices +node Start, stop, restart, or pause individual containerlab nodes + tc Disable, enable, display, or modify link impairment parameters on libvirt virtual machines or containerlab-created Docker containers. diff --git a/netsim/providers/clab/__init__.py b/netsim/providers/clab/__init__.py index abb057926a..d3ed219b43 100644 --- a/netsim/providers/clab/__init__.py +++ b/netsim/providers/clab/__init__.py @@ -103,7 +103,7 @@ def health_check(self, nodeset: list, topology: Box) -> None: def get_lab_status(self,collect_status: dict) -> Box: try: status = external_commands.run_command( - 'docker ps --format json', + 'docker ps -a --format json', # -a: show stopped nodes as 'Exited' check_result=True, ignore_errors=True, return_stdout=True, @@ -222,6 +222,17 @@ def capture_command(self, node: Box, topology: Box, args: argparse.Namespace) -> node_name = self.get_node_name(node.name,topology) return strings.string_to_list(f'sudo ip netns exec {node_name}') + cmd + def control_node(self, node: Box, topology: Box, action: str) -> bool: + labops.set_clab_runtime(topology) + if action in ['pause','unpause']: # Containerlab cannot pause containers + cmd = [ 'docker', action, self.get_node_name(node.name,topology) ] + else: + c_action = { 'up': 'start', 'down': 'stop' }.get(action,action) + c_topo = 'clab.yml' if topology.provider == 'clab' else 'clab-augment.yml' + cmd = [ 'containerlab', c_action, '-t', c_topo, '--node', node.name ] + + return bool(external_commands.run_command(cmd,ignore_errors=True)) # containerlab logs to stderr, check exit code only + def set_tc(self, node: Box, topology: Box, intf: Box, error: bool = True) -> None: c_name = self.get_node_name(node.name,topology) c_intf = intf.get('clab.name',intf.ifname).replace('/','-') diff --git a/netsim/providers/clab/labops.py b/netsim/providers/clab/labops.py index f05052b9e3..543e3d9b47 100644 --- a/netsim/providers/clab/labops.py +++ b/netsim/providers/clab/labops.py @@ -152,7 +152,7 @@ def load_kmods(topology: Box) -> None: def set_clab_runtime(topology: Box) -> None: runtime = topology.get('defaults.providers.clab.runtime',None) - if not runtime: + if not runtime or os.environ.get('CLAB_RUNTIME',None) == str(runtime): return log.info(f'Setting containerlab runtime to {runtime}') From 7af06cdb2cc631bd5ae9ebfb06ee6506f65126ed Mon Sep 17 00:00:00 2001 From: Muddyblack <70057554+Muddyblack@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:58:27 +0200 Subject: [PATCH 2/3] Address review: skip unmanaged nodes, check podman root first netlab node loads the snapshot with ghosts=False (unmanaged nodes are not in clab.yml). set_clab_runtime checks for root before the CLAB_RUNTIME shortcut. --- netsim/cli/node.py | 2 +- netsim/providers/clab/labops.py | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/netsim/cli/node.py b/netsim/cli/node.py index 9b031e0f2f..f0abd04c04 100644 --- a/netsim/cli/node.py +++ b/netsim/cli/node.py @@ -40,7 +40,7 @@ def run(cli_args: typing.List[str]) -> None: log.set_logging_flags(args) set_dry_run(args) - topology = load_snapshot(args) + topology = load_snapshot(args,ghosts=False) node_list = _nodeset.parse_nodeset(args.node,topology) log.exit_on_error() diff --git a/netsim/providers/clab/labops.py b/netsim/providers/clab/labops.py index 543e3d9b47..0323d19394 100644 --- a/netsim/providers/clab/labops.py +++ b/netsim/providers/clab/labops.py @@ -152,14 +152,18 @@ def load_kmods(topology: Box) -> None: def set_clab_runtime(topology: Box) -> None: runtime = topology.get('defaults.providers.clab.runtime',None) - if not runtime or os.environ.get('CLAB_RUNTIME',None) == str(runtime): + if not runtime: return - log.info(f'Setting containerlab runtime to {runtime}') - os.environ['CLAB_RUNTIME'] = str(runtime) if runtime == 'podman' and os.geteuid() != 0: log.fatal('You must run netlab as the root user when using podman') + if os.environ.get('CLAB_RUNTIME',None) == str(runtime): + return + + log.info(f'Setting containerlab runtime to {runtime}') + os.environ['CLAB_RUNTIME'] = str(runtime) + def health_ready_setup(topology: Box) -> None: """ Modify device definitions to prepare for the container healthcheck. For every From 62d0b017242fba9b9b7ba0acd282cfd4988a979f Mon Sep 17 00:00:00 2001 From: Muddyblack <70057554+Muddyblack@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:01:06 +0200 Subject: [PATCH 3/3] netlab node: configure started nodes, action-first syntax up/restart now run 'netlab initial --limit' (or 'netlab config --reload' with -r), unless --no-config is used. Several nodes can be specified. --- docs/netlab/node.md | 26 +++++++++++++++----------- netsim/cli/node.py | 38 ++++++++++++++++++++++++++++++++++---- 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/docs/netlab/node.md b/docs/netlab/node.md index a1e70e3b16..4cdb5d3f35 100644 --- a/docs/netlab/node.md +++ b/docs/netlab/node.md @@ -5,53 +5,57 @@ The **netlab node** command starts, stops, restarts, pauses, or unpauses individ ```{warning} * **netlab node** works only with *containerlab* nodes. -* A restarted container loses the device configuration deployed by _netlab_. Use **netlab initial --limit _node_** to configure it again. +* A restarted container loses the device configuration deployed by _netlab_. **netlab node up** and **netlab node restart** therefore run **netlab initial** (or **netlab config --reload**) on the affected nodes unless you use the `--no-config` flag. ``` ## Usage ```text -usage: netlab node [-h] [-v] [-q] [--dry-run] [-i INSTANCE] node {up,down,restart,pause,unpause} +usage: netlab node [-h] [-v] [-q] [--dry-run] [--no-config] [-r RELOAD] [-i INSTANCE] + {up,down,restart,pause,unpause} node [node ...] Start, stop, restart, pause, or unpause individual lab nodes positional arguments: - node Node(s) to act on (node names, groups, or globs, separated by commas) {up,down,restart,pause,unpause} Action to execute on the selected nodes + node Node(s) to act on (node names, groups, or globs; separate them with spaces or commas) options: -h, --help show this help message and exit -v, --verbose Verbose logging (add multiple flags for increased verbosity) -q, --quiet Report only major errors --dry-run Print the commands that would be executed, but do not execute them + --no-config Do not configure the started nodes + -r RELOAD, --reload-config RELOAD + Reload saved configurations from specified directory -i INSTANCE, --instance INSTANCE Specify the lab instance to change nodes in ``` -The **netlab node** command takes two parameters: +The **netlab node** command takes these parameters: -* A [list of nodes](netlab-inspect-node): node names, group names, or globs, separated by commas. * The action to execute: * **down** stops the node container (`containerlab stop`) * **up** starts a stopped node container and reconnects its links (`containerlab start`) * **restart** stops and starts the node container (`containerlab restart`) * **pause** freezes all processes in the node container (`docker pause`) * **unpause** resumes a paused node container (`docker unpause`) +* A [list of nodes](netlab-inspect-node): node names, group names, or globs, separated by spaces or commas. Notes: -* **netlab node** reads the transformed lab topology from the `netlab.snapshot.pickle` file created by **netlab up** or **netlab create**. +* **netlab node** reads the transformed lab topology from the `netlab.snapshot.pickle` file created by **netlab up** or **netlab create**. Unmanaged nodes are ignored. +* After starting or restarting the nodes, **netlab node** deploys the initial device configuration with **netlab initial --limit _nodes_**. Use `--no-config` to skip this step, or `--reload-config _directory_` to reload saved configurations with **netlab config --reload** instead. * With the `--instance` flag, you can change nodes in a lab instance running in a different directory. * **[netlab status](status.md)** displays the state of stopped and paused containers. -* Some devices reload their saved configuration (for example, FRR after **write memory**) when restarted, but that does not restore VLANs, VRFs, LAGs, or VXLAN. * Routing protocol sessions with a paused node might time out. ## Examples ```bash -$ netlab node r1 down -$ netlab node r1 up -$ netlab initial --limit r1 -$ netlab node r1,r2 pause +$ netlab node down r1 +$ netlab node up r1 +$ netlab node restart r1 r2 --no-config +$ netlab node pause r1,r2 ``` diff --git a/netsim/cli/node.py b/netsim/cli/node.py index f0abd04c04..2a246b2fbd 100644 --- a/netsim/cli/node.py +++ b/netsim/cli/node.py @@ -8,7 +8,7 @@ from ..providers import execute_node from ..utils import log -from . import _nodeset, load_snapshot, parser_add_verbose, parser_lab_location, set_dry_run +from . import _nodeset, external_commands, load_snapshot, parser_add_verbose, parser_lab_location, set_dry_run # @@ -25,23 +25,50 @@ def node_parse(args: typing.List[str]) -> argparse.Namespace: action='store_true', help='Print the commands that would be executed, but do not execute them') parser.add_argument( - dest='node', action='store', - help='Node(s) to act on (node names, groups, or globs, separated by commas)') + '--no-config', + dest='no_config', + action='store_true', + help='Do not configure the started nodes') + parser.add_argument( + '-r','--reload-config', + dest='reload', + action='store', + help='Reload saved configurations from specified directory') parser.add_argument( dest='action', action='store', choices=['up','down','restart','pause','unpause'], help='Action to execute on the selected nodes') + parser.add_argument( + dest='node', action='store', nargs='+', + help='Node(s) to act on (node names, groups, or globs; separate them with spaces or commas)') parser_lab_location(parser,instance=True,action='change nodes in') return parser.parse_args(args) +# +# A restarted container loses its configuration: deploy the initial configuration +# (or reload the saved one) on the nodes we've just started +# +def configure_nodes(args: argparse.Namespace, node_list: typing.List[str]) -> None: + limit = ','.join(node_list) + if args.reload: + cmd = ['netlab','config','--reload',args.reload,'--limit',limit] + else: + cmd = ['netlab','initial','--no-message','--limit',limit] + + if not external_commands.run_command(external_commands.set_ansible_flags(cmd)): + log.fatal(f'netlab {cmd[1]} failed on nodes {limit}, aborting...','netlab node') + def run(cli_args: typing.List[str]) -> None: args = node_parse(cli_args) log.set_logging_flags(args) set_dry_run(args) + if args.reload and args.no_config: + log.fatal('Cannot combine --reload-config and --no-config') + topology = load_snapshot(args,ghosts=False) - node_list = _nodeset.parse_nodeset(args.node,topology) + node_list = _nodeset.parse_nodeset(','.join(args.node),topology) log.exit_on_error() for node in node_list: @@ -52,3 +79,6 @@ def run(cli_args: typing.List[str]) -> None: log.error(f'Failed to {args.action} node {node}',module='node',skip_header=True) log.exit_on_error() + + if args.action in ('up','restart') and not args.no_config: + configure_nodes(args,node_list)