From 4facb0ab67f0c72f482af4e9ee215492f7fbd65b Mon Sep 17 00:00:00 2001 From: Seb d'Argoeuves Date: Tue, 6 Jan 2026 10:59:34 +0000 Subject: [PATCH 01/11] add function to render Dockerfile.j2 template --- netsim/cli/clab_actions/build.py | 74 ++++++++++++++++++++++++++++++-- 1 file changed, 70 insertions(+), 4 deletions(-) diff --git a/netsim/cli/clab_actions/build.py b/netsim/cli/clab_actions/build.py index aa64ca9c7d..97c03c6a17 100644 --- a/netsim/cli/clab_actions/build.py +++ b/netsim/cli/clab_actions/build.py @@ -12,7 +12,8 @@ from box import Box from ...utils import files as _files -from ...utils import log, strings +from ...utils import log, strings, templates +from ...utils import read as _read from .. import external_commands @@ -44,7 +45,15 @@ def get_dockerfiles() -> dict: for d_file in d_list: daemon = os.path.basename(os.path.dirname(d_file)) root, ext = os.path.splitext(d_file) - df_dict[daemon+ext] = d_file + + # For .j2 files, use the base daemon name without .j2 extension + # This allows "netlab clab build netscaler" to find "netscaler/Dockerfile.j2" + daemon_key = daemon if not ext == '.j2' else daemon + + # Store with full extension for internal use, but key by daemon name + # If both Dockerfile and Dockerfile.j2 exist, prefer .j2 + if daemon_key not in df_dict or ext == '.j2': + df_dict[daemon_key] = d_file return df_dict @@ -52,6 +61,10 @@ def get_description(dfname: str) -> str: try: df_lines = pathlib.Path(dfname).read_text().split('\n') for line in df_lines: + # # Skip Jinja2 directives + # if line.strip().startswith('{%') or line.strip().startswith('{{'): + # continue + if not line.startswith('LABEL'): continue @@ -65,6 +78,53 @@ def get_description(dfname: str) -> str: return '???' +def render_j2_dockerfile(df_path: str, tmp_dir: str) -> str: + """ + Render Dockerfile.j2 if needed, return path to use for build. + + If the Dockerfile ends with .j2, it's a Jinja2 template and needs to be rendered + with netlab device defaults before building. + """ + if not df_path.endswith('.j2'): + return df_path # Regular Dockerfile, use as-is + + strings.print_colored_text('[TEMPLATE] ','cyan',None) + print(f"Rendering Jinja2 template from {os.path.basename(df_path)}") + + # Jinja2 function to fail template rendering with a custom error message + def fail(msg: str) -> None: + raise ValueError(msg) + + # Load topology defaults to get device credentials + try: + defaults = _read.system_defaults().defaults + except Exception as ex: + log.error(f'Could not load topology defaults: {ex}', log.IncorrectValue, 'build') + log.error('Continuing with empty defaults...', log.IncorrectValue, 'build') + defaults = Box({}, default_box=True) + + # Render template + try: + rendered_content = templates.render_template( + data={'defaults': defaults, 'fail': fail}, + j2_file=os.path.basename(df_path), + path=os.path.dirname(df_path) + ) + except Exception as ex: + log.fatal( + f'Failed to render Dockerfile template {os.path.basename(df_path)}: {str(ex)}', + module='build') + + # Write to temp directory + rendered_path = os.path.join(tmp_dir, 'Dockerfile') + with open(rendered_path, 'w') as f: + f.write(rendered_content) + + strings.print_colored_text('[RENDERED] ','green',None) + print(f"Template rendered to temporary Dockerfile") + + return rendered_path + def build_image(image: str, tag: typing.Optional[str]) -> None: if tag is None or not tag: tag = f'netlab/{image}:latest' @@ -93,8 +153,12 @@ def build_image(image: str, tag: typing.Optional[str]) -> None: with tempfile.TemporaryDirectory() as tmp: os.chdir(tmp) + + # Render Dockerfile.j2 if needed, otherwise use original path + dockerfile_to_use = render_j2_dockerfile(df_dict[image], tmp) + status = external_commands.run_command( - f'docker build -t {tag} -f {df_dict[image]} .', + f'docker build -t {tag} -f {dockerfile_to_use} .', ignore_errors=True, check_result=False) if status: @@ -112,7 +176,9 @@ def list_dockerfiles() -> None: rows = [] df_dict = get_dockerfiles() for daemon in sorted(df_dict.keys()): - rows.append([daemon, f'netlab/{daemon}:latest', get_description(df_dict[daemon])]) + # Strip .j2 extension from daemon name if present for display + display_name = daemon.replace('.j2', '') + rows.append([display_name, f'netlab/{display_name}:latest', get_description(df_dict[daemon])]) print(""" The 'netlab clab build' command can be used to build the following container images From 843902e8eec913eb5e9a6805dd1b78af3a38d243 Mon Sep 17 00:00:00 2001 From: Seb d'Argoeuves Date: Tue, 6 Jan 2026 11:00:41 +0000 Subject: [PATCH 02/11] create netscaler Dockerfile.j2 template --- .../netscaler/{Dockerfile => Dockerfile.j2} | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) rename netsim/daemons/netscaler/{Dockerfile => Dockerfile.j2} (70%) diff --git a/netsim/daemons/netscaler/Dockerfile b/netsim/daemons/netscaler/Dockerfile.j2 similarity index 70% rename from netsim/daemons/netscaler/Dockerfile rename to netsim/daemons/netscaler/Dockerfile.j2 index 4e1a56d29d..d6297af47b 100644 --- a/netsim/daemons/netscaler/Dockerfile +++ b/netsim/daemons/netscaler/Dockerfile.j2 @@ -1,10 +1,16 @@ +{% set _user = defaults.devices.netscaler.clab.group_vars.ansible_user %} +{% set _password = defaults.devices.netscaler.clab.group_vars.ansible_ssh_pass %} +{% if not _user %}{{ fail('ansible_user must be defined in devices.netscaler.clab.group_vars') }}{% endif %} +{% if not _password %}{{ fail('ansible_ssh_pass must be defined in devices.netscaler.clab.group_vars') }}{% endif %} FROM quay.io/netscaler/netscaler-cpx:13.1-60.29 +LABEL description="Citrix Netscaler ADC CPX for containerlab" + ENV EULA=yes -# Build arguments for clab user credentials -ARG CLAB_USER=clab -ARG CLAB_PASSWORD=clab@123 +# Build arguments for clab user credentials (using netlab device defaults) +ARG CLAB_USER={{ _user }} +ARG CLAB_PASSWORD={{ _password }} ENV CLAB_USER=${CLAB_USER} ENV CLAB_PASSWORD=${CLAB_PASSWORD} @@ -32,7 +38,7 @@ RUN usermod -s /usr/local/bin/nscli-wrapper ${CLAB_USER} # Entrypoint - runs startup and also provisioning if container is not already initialized # the timing of 120 seconds is required to ensure the netscaler is fully started before creating the user -# when tested with 14.1, 120 was no longer enough, it might need to be increased to 180 seconds for future versions +# when tested with 14.1, 120 was not enough, it might need to be increased to 180 seconds for future versions RUN (echo '#!/bin/bash'; \ echo 'if [ ! -f /initialized ]; then'; \ echo ' /var/netscaler/bins/docker_startup.sh &'; \ From 3702506a0ab4369a437743d5d18008a8b9e51146 Mon Sep 17 00:00:00 2001 From: Seb d'Argoeuves Date: Tue, 6 Jan 2026 11:04:49 +0000 Subject: [PATCH 03/11] Add basic support for netscaler --- .../ansible/tasks/deploy-config/netscaler.yml | 8 +++++ .../tasks/readiness-check/netscaler.yml | 19 ++++++++++++ netsim/ansible/templates/initial/netscaler.j2 | 5 +++ netsim/devices/netscaler.yml | 31 +++++++++++++++++++ 4 files changed, 63 insertions(+) create mode 100644 netsim/ansible/tasks/deploy-config/netscaler.yml create mode 100644 netsim/ansible/tasks/readiness-check/netscaler.yml create mode 100644 netsim/ansible/templates/initial/netscaler.j2 create mode 100644 netsim/devices/netscaler.yml diff --git a/netsim/ansible/tasks/deploy-config/netscaler.yml b/netsim/ansible/tasks/deploy-config/netscaler.yml new file mode 100644 index 0000000000..88752d061e --- /dev/null +++ b/netsim/ansible/tasks/deploy-config/netscaler.yml @@ -0,0 +1,8 @@ +- name: "Netscaler: deploying {{ netsim_action }} from {{ config_template }}" + delegate_to: localhost + ansible.builtin.shell: | + echo '{{ lookup("template", config_template) }}' | while IFS= read -r line; do + [ -n "$line" ] && docker exec {{ hostname }} nscli -U '127.0.0.1:{{ ansible_user }}:{{ ansible_ssh_pass }}' "$line" || true + done + when: not ansible_check_mode + tags: [ print_action, always ] diff --git a/netsim/ansible/tasks/readiness-check/netscaler.yml b/netsim/ansible/tasks/readiness-check/netscaler.yml new file mode 100644 index 0000000000..633dac9302 --- /dev/null +++ b/netsim/ansible/tasks/readiness-check/netscaler.yml @@ -0,0 +1,19 @@ +--- +# +# Netscaler readiness check - wait for nscli to be available +# +- name: Check {{ netlab_device_type|default(inventory_hostname) }} nscli readiness + delegate_to: localhost + ansible.builtin.shell: | + docker exec {{ hostname }} nscli -U '127.0.0.1:{{ ansible_user }}:{{ ansible_ssh_pass }}' 'show ns hostName' + changed_when: False + register: command_out + until: command_out.rc == 0 and "Done" in command_out.stdout + retries: "{{ netlab_check_retries | default(60) }}" + delay: "{{ netlab_check_delay | default(5) }}" + failed_when: False + +- name: Confirm {{ inventory_hostname }} is ready + debug: + msg: "Node {{ inventory_hostname }} is ready (nscli responding)." + when: command_out.rc == 0 diff --git a/netsim/ansible/templates/initial/netscaler.j2 b/netsim/ansible/templates/initial/netscaler.j2 new file mode 100644 index 0000000000..cdfb8124d7 --- /dev/null +++ b/netsim/ansible/templates/initial/netscaler.j2 @@ -0,0 +1,5 @@ +set ns hostName {{ inventory_hostname.replace("_","-") }} + +{% for l in interfaces|default([]) %} +set interface {{ l.ifname }} -ifAlias "{{ l.name }}{{ " ["+l.role+"]" if l.role is defined else "" }}" -ifnum {{ l.ifname }} -lldpmode TRANSCEIVER +{% endfor %} diff --git a/netsim/devices/netscaler.yml b/netsim/devices/netscaler.yml new file mode 100644 index 0000000000..0d26136d69 --- /dev/null +++ b/netsim/devices/netscaler.yml @@ -0,0 +1,31 @@ +--- +description: Citrix Netscaler ADC CPX +support: + level: minimal +interface_name: 0/{ifindex+2} # 0/3 or eth1 will be the first interface after mgmt +mgmt_if: 0/2 # or eth0 +role: router +group_vars: + ansible_network_os: netscaler + ansible_python_interpreter: auto_silent +features: + initial: + ipv4: +mtu: 1500 +clab: + build: https://netlab.tools/labs/netscaler/ + # image: netlab/netscaler + node: + kind: linux # kind not yet available in Containerlab + interface: + name: eth{ifindex} + group_vars: + ansible_user: clab + ansible_ssh_pass: clab@123 + ansible_connection: docker + netlab_show_command: [ nscli, -U, '127.0.0.1:clab:clab@123', 'show $@' ] + +external: + image: none + +graphite.icon: server From 242f9468e4a66def031f3989783906ade829ca56 Mon Sep 17 00:00:00 2001 From: Seb d'Argoeuves Date: Tue, 6 Jan 2026 11:50:02 +0000 Subject: [PATCH 04/11] fix YAML lint issues --- netsim/ansible/tasks/deploy-config/netscaler.yml | 3 ++- netsim/devices/netscaler.yml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/netsim/ansible/tasks/deploy-config/netscaler.yml b/netsim/ansible/tasks/deploy-config/netscaler.yml index 88752d061e..3449121632 100644 --- a/netsim/ansible/tasks/deploy-config/netscaler.yml +++ b/netsim/ansible/tasks/deploy-config/netscaler.yml @@ -2,7 +2,8 @@ delegate_to: localhost ansible.builtin.shell: | echo '{{ lookup("template", config_template) }}' | while IFS= read -r line; do - [ -n "$line" ] && docker exec {{ hostname }} nscli -U '127.0.0.1:{{ ansible_user }}:{{ ansible_ssh_pass }}' "$line" || true + [ -n "$line" ] && docker exec {{ hostname }} nscli \ + -U '127.0.0.1:{{ ansible_user }}:{{ ansible_ssh_pass }}' "$line" || true done when: not ansible_check_mode tags: [ print_action, always ] diff --git a/netsim/devices/netscaler.yml b/netsim/devices/netscaler.yml index 0d26136d69..6a4fcfeadd 100644 --- a/netsim/devices/netscaler.yml +++ b/netsim/devices/netscaler.yml @@ -1,5 +1,5 @@ --- -description: Citrix Netscaler ADC CPX +description: Citrix Netscaler ADC CPX support: level: minimal interface_name: 0/{ifindex+2} # 0/3 or eth1 will be the first interface after mgmt From cea3c787fca8ac050571301c7b1737c1af59fddc Mon Sep 17 00:00:00 2001 From: Seb d'Argoeuves Date: Wed, 7 Jan 2026 01:10:37 +0000 Subject: [PATCH 05/11] Fixes suggestion for adding Netscaler device --- .../ansible/tasks/deploy-config/netscaler.yml | 21 +++++++++++++------ .../tasks/readiness-check/netscaler.yml | 6 ++---- netsim/ansible/templates/initial/netscaler.j2 | 12 ++++++++++- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/netsim/ansible/tasks/deploy-config/netscaler.yml b/netsim/ansible/tasks/deploy-config/netscaler.yml index 3449121632..bd72ccf64b 100644 --- a/netsim/ansible/tasks/deploy-config/netscaler.yml +++ b/netsim/ansible/tasks/deploy-config/netscaler.yml @@ -1,9 +1,18 @@ +- ansible.builtin.set_fact: + deployed_config: "{{ lookup('template', config_template) }}" + - name: "Netscaler: deploying {{ netsim_action }} from {{ config_template }}" - delegate_to: localhost - ansible.builtin.shell: | - echo '{{ lookup("template", config_template) }}' | while IFS= read -r line; do - [ -n "$line" ] && docker exec {{ hostname }} nscli \ - -U '127.0.0.1:{{ ansible_user }}:{{ ansible_ssh_pass }}' "$line" || true - done + ansible.builtin.raw: | + cat > /tmp/deploy.sh << 'NETSCALER_EOF' + #!/bin/bash + while IFS= read -r line; do + [ -n "$line" ] && nscli -U 127.0.0.1:{{ ansible_user }}:{{ ansible_ssh_pass }} "$line" || true + done << 'CONFIG_EOF' + {{ deployed_config }} + CONFIG_EOF + nscli -U 127.0.0.1:{{ ansible_user }}:{{ ansible_ssh_pass }} "save ns config" + NETSCALER_EOF + chmod +x /tmp/deploy.sh + sh /tmp/deploy.sh when: not ansible_check_mode tags: [ print_action, always ] diff --git a/netsim/ansible/tasks/readiness-check/netscaler.yml b/netsim/ansible/tasks/readiness-check/netscaler.yml index 633dac9302..e64f552fa7 100644 --- a/netsim/ansible/tasks/readiness-check/netscaler.yml +++ b/netsim/ansible/tasks/readiness-check/netscaler.yml @@ -3,9 +3,7 @@ # Netscaler readiness check - wait for nscli to be available # - name: Check {{ netlab_device_type|default(inventory_hostname) }} nscli readiness - delegate_to: localhost - ansible.builtin.shell: | - docker exec {{ hostname }} nscli -U '127.0.0.1:{{ ansible_user }}:{{ ansible_ssh_pass }}' 'show ns hostName' + ansible.builtin.raw: nscli -U '127.0.0.1:{{ ansible_user }}:{{ ansible_ssh_pass }}' 'show ns hostName' changed_when: False register: command_out until: command_out.rc == 0 and "Done" in command_out.stdout @@ -14,6 +12,6 @@ failed_when: False - name: Confirm {{ inventory_hostname }} is ready - debug: + ansible.builtin.debug: msg: "Node {{ inventory_hostname }} is ready (nscli responding)." when: command_out.rc == 0 diff --git a/netsim/ansible/templates/initial/netscaler.j2 b/netsim/ansible/templates/initial/netscaler.j2 index cdfb8124d7..2cd92ff760 100644 --- a/netsim/ansible/templates/initial/netscaler.j2 +++ b/netsim/ansible/templates/initial/netscaler.j2 @@ -1,5 +1,15 @@ + set ns hostName {{ inventory_hostname.replace("_","-") }} {% for l in interfaces|default([]) %} -set interface {{ l.ifname }} -ifAlias "{{ l.name }}{{ " ["+l.role+"]" if l.role is defined else "" }}" -ifnum {{ l.ifname }} -lldpmode TRANSCEIVER +set interface {{ l.ifname }} -ifAlias "{{ l.name }}{{ + " ["+l.role+"]" if l.role is defined else "" }}" -ifnum {{ + l.ifname }} -lldpmode TRANSCEIVER +{% if l.ipv4 is defined %} +{% set vlan_id = 3990 + loop.index %} +add vlan {{ vlan_id }} -aliasName Vlan{{ vlan_id }} +add ns ip {{ l.ipv4|ansible.utils.ipaddr('address') }} {{ l.ipv4|ansible.utils.ipaddr('netmask') }} -vServer DISABLED +bind vlan {{ vlan_id }} -ifnum {{ l.ifname }} +bind vlan {{ vlan_id }} -IPAddress {{ l.ipv4|ansible.utils.ipaddr('address') }} {{ l.ipv4|ansible.utils.ipaddr('netmask') }} +{% endif %} {% endfor %} From 4a4a8dd6e6ffa8dded9cd1fa4c7abeb504be6f5a Mon Sep 17 00:00:00 2001 From: Seb d'Argoeuves Date: Wed, 7 Jan 2026 01:09:43 +0000 Subject: [PATCH 06/11] build container based on Dockerfile.j2 --- netsim/cli/clab_actions/build.py | 32 ++++++-------------------- netsim/daemons/netscaler/Dockerfile.j2 | 4 ++-- 2 files changed, 9 insertions(+), 27 deletions(-) diff --git a/netsim/cli/clab_actions/build.py b/netsim/cli/clab_actions/build.py index 97c03c6a17..024915e9ae 100644 --- a/netsim/cli/clab_actions/build.py +++ b/netsim/cli/clab_actions/build.py @@ -45,15 +45,9 @@ def get_dockerfiles() -> dict: for d_file in d_list: daemon = os.path.basename(os.path.dirname(d_file)) root, ext = os.path.splitext(d_file) - - # For .j2 files, use the base daemon name without .j2 extension - # This allows "netlab clab build netscaler" to find "netscaler/Dockerfile.j2" - daemon_key = daemon if not ext == '.j2' else daemon - - # Store with full extension for internal use, but key by daemon name - # If both Dockerfile and Dockerfile.j2 exist, prefer .j2 - if daemon_key not in df_dict or ext == '.j2': - df_dict[daemon_key] = d_file + # If the Dockerfile has a .j2 extension, keep it in the key name + ext = ext.replace('.j2', '') + df_dict[daemon + ext] = d_file return df_dict @@ -61,18 +55,12 @@ def get_description(dfname: str) -> str: try: df_lines = pathlib.Path(dfname).read_text().split('\n') for line in df_lines: - # # Skip Jinja2 directives - # if line.strip().startswith('{%') or line.strip().startswith('{{'): - # continue - if not line.startswith('LABEL'): continue - if not 'description=' in line: continue - return line.split('description=')[1].replace('"','') - + except: return '-- failed --' @@ -91,22 +79,16 @@ def render_j2_dockerfile(df_path: str, tmp_dir: str) -> str: strings.print_colored_text('[TEMPLATE] ','cyan',None) print(f"Rendering Jinja2 template from {os.path.basename(df_path)}") - # Jinja2 function to fail template rendering with a custom error message - def fail(msg: str) -> None: - raise ValueError(msg) - # Load topology defaults to get device credentials try: defaults = _read.system_defaults().defaults except Exception as ex: - log.error(f'Could not load topology defaults: {ex}', log.IncorrectValue, 'build') - log.error('Continuing with empty defaults...', log.IncorrectValue, 'build') - defaults = Box({}, default_box=True) + log.fatal(f'Could not load system defaults: {str(ex)}', module='build') - # Render template + # Render template (fail() is available as a standard Jinja2 global function) try: rendered_content = templates.render_template( - data={'defaults': defaults, 'fail': fail}, + data={'defaults': defaults}, j2_file=os.path.basename(df_path), path=os.path.dirname(df_path) ) diff --git a/netsim/daemons/netscaler/Dockerfile.j2 b/netsim/daemons/netscaler/Dockerfile.j2 index d6297af47b..5125782e25 100644 --- a/netsim/daemons/netscaler/Dockerfile.j2 +++ b/netsim/daemons/netscaler/Dockerfile.j2 @@ -1,7 +1,7 @@ {% set _user = defaults.devices.netscaler.clab.group_vars.ansible_user %} {% set _password = defaults.devices.netscaler.clab.group_vars.ansible_ssh_pass %} -{% if not _user %}{{ fail('ansible_user must be defined in devices.netscaler.clab.group_vars') }}{% endif %} -{% if not _password %}{{ fail('ansible_ssh_pass must be defined in devices.netscaler.clab.group_vars') }}{% endif %} +{% if not _user %}{{ None | fail('ansible_user must be defined in devices.netscaler.clab.group_vars') }}{% endif %} +{% if not _password %}{{ None | fail('ansible_ssh_pass must be defined in devices.netscaler.clab.group_vars') }}{% endif %} FROM quay.io/netscaler/netscaler-cpx:13.1-60.29 LABEL description="Citrix Netscaler ADC CPX for containerlab" From 426ef9d7a09f1049d5d1aab035f7690664261de1 Mon Sep 17 00:00:00 2001 From: Seb d'Argoeuves Date: Wed, 7 Jan 2026 01:07:42 +0000 Subject: [PATCH 07/11] Add fail() Jinja2 template --- netsim/utils/filters.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/netsim/utils/filters.py b/netsim/utils/filters.py index eb8ca1df68..6f084c34fc 100644 --- a/netsim/utils/filters.py +++ b/netsim/utils/filters.py @@ -102,6 +102,16 @@ def j2_hwaddr(value: typing.Any, format: str = '') -> str: else: # Otherwise it was a filter query, return empty string return '' +# Fail template rendering with a custom error message +# +def j2_fail(value: typing.Any, msg: str) -> None: + """ + Jinja2 filter to fail template rendering with a custom error message. + Use in templates: {{ None | fail('Error message') }} + The first argument (value) is ignored. + """ + raise ValueError(msg) + class j2_Undefined(StrictUndefined): """ Mimics Ansible's undefined variable handling in Jinja2 templates. @@ -131,7 +141,8 @@ def defined(self) -> bool: 'ipaddr': j2_ipaddr, 'ipv4': j2_ipv4, 'ipv6': j2_ipv6, - 'hwaddr': j2_hwaddr + 'hwaddr': j2_hwaddr, + 'fail': j2_fail } """ From 6120218eb6cce691787881581859d1db791d63a6 Mon Sep 17 00:00:00 2001 From: Seb d'Argoeuves Date: Wed, 7 Jan 2026 02:17:35 +0000 Subject: [PATCH 08/11] reuse write_template to build Dockerfile from j2 --- netsim/cli/clab_actions/build.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/netsim/cli/clab_actions/build.py b/netsim/cli/clab_actions/build.py index 024915e9ae..3ac5d21c1b 100644 --- a/netsim/cli/clab_actions/build.py +++ b/netsim/cli/clab_actions/build.py @@ -87,25 +87,16 @@ def render_j2_dockerfile(df_path: str, tmp_dir: str) -> str: # Render template (fail() is available as a standard Jinja2 global function) try: - rendered_content = templates.render_template( - data={'defaults': defaults}, - j2_file=os.path.basename(df_path), - path=os.path.dirname(df_path) - ) + templates.write_template(os.path.dirname(df_path), os.path.basename(df_path), {'defaults': defaults}, tmp_dir, 'Dockerfile') except Exception as ex: log.fatal( f'Failed to render Dockerfile template {os.path.basename(df_path)}: {str(ex)}', module='build') - # Write to temp directory - rendered_path = os.path.join(tmp_dir, 'Dockerfile') - with open(rendered_path, 'w') as f: - f.write(rendered_content) - strings.print_colored_text('[RENDERED] ','green',None) print(f"Template rendered to temporary Dockerfile") - return rendered_path + return os.path.join(tmp_dir, 'Dockerfile') def build_image(image: str, tag: typing.Optional[str]) -> None: if tag is None or not tag: From dd58d3b883bc8ccd302f1572b59718c6477a47be Mon Sep 17 00:00:00 2001 From: Seb d'Argoeuves Date: Wed, 7 Jan 2026 17:47:10 +0000 Subject: [PATCH 09/11] use l.ifindex for Vnan number --- netsim/ansible/templates/initial/netscaler.j2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netsim/ansible/templates/initial/netscaler.j2 b/netsim/ansible/templates/initial/netscaler.j2 index 2cd92ff760..191b0c21f5 100644 --- a/netsim/ansible/templates/initial/netscaler.j2 +++ b/netsim/ansible/templates/initial/netscaler.j2 @@ -6,7 +6,7 @@ set interface {{ l.ifname }} -ifAlias "{{ l.name }}{{ " ["+l.role+"]" if l.role is defined else "" }}" -ifnum {{ l.ifname }} -lldpmode TRANSCEIVER {% if l.ipv4 is defined %} -{% set vlan_id = 3990 + loop.index %} +{% set vlan_id = 3990 + l.ifindex %} add vlan {{ vlan_id }} -aliasName Vlan{{ vlan_id }} add ns ip {{ l.ipv4|ansible.utils.ipaddr('address') }} {{ l.ipv4|ansible.utils.ipaddr('netmask') }} -vServer DISABLED bind vlan {{ vlan_id }} -ifnum {{ l.ifname }} From 58e5d4f5078057e6c5b20890744042a2af9288c5 Mon Sep 17 00:00:00 2001 From: Seb d'Argoeuves Date: Wed, 7 Jan 2026 17:55:17 +0000 Subject: [PATCH 10/11] use fail() as global function for j2 --- netsim/daemons/netscaler/Dockerfile.j2 | 4 ++-- netsim/utils/filters.py | 10 ++++------ netsim/utils/templates.py | 3 +++ 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/netsim/daemons/netscaler/Dockerfile.j2 b/netsim/daemons/netscaler/Dockerfile.j2 index 5125782e25..d6297af47b 100644 --- a/netsim/daemons/netscaler/Dockerfile.j2 +++ b/netsim/daemons/netscaler/Dockerfile.j2 @@ -1,7 +1,7 @@ {% set _user = defaults.devices.netscaler.clab.group_vars.ansible_user %} {% set _password = defaults.devices.netscaler.clab.group_vars.ansible_ssh_pass %} -{% if not _user %}{{ None | fail('ansible_user must be defined in devices.netscaler.clab.group_vars') }}{% endif %} -{% if not _password %}{{ None | fail('ansible_ssh_pass must be defined in devices.netscaler.clab.group_vars') }}{% endif %} +{% if not _user %}{{ fail('ansible_user must be defined in devices.netscaler.clab.group_vars') }}{% endif %} +{% if not _password %}{{ fail('ansible_ssh_pass must be defined in devices.netscaler.clab.group_vars') }}{% endif %} FROM quay.io/netscaler/netscaler-cpx:13.1-60.29 LABEL description="Citrix Netscaler ADC CPX for containerlab" diff --git a/netsim/utils/filters.py b/netsim/utils/filters.py index 6f084c34fc..619bad8e54 100644 --- a/netsim/utils/filters.py +++ b/netsim/utils/filters.py @@ -104,11 +104,10 @@ def j2_hwaddr(value: typing.Any, format: str = '') -> str: # Fail template rendering with a custom error message # -def j2_fail(value: typing.Any, msg: str) -> None: +def j2_fail(msg: str) -> None: """ - Jinja2 filter to fail template rendering with a custom error message. - Use in templates: {{ None | fail('Error message') }} - The first argument (value) is ignored. + Jinja2 global function to fail template rendering with a custom error message. + Use in templates: {{ fail('Error message') }} """ raise ValueError(msg) @@ -141,8 +140,7 @@ def defined(self) -> bool: 'ipaddr': j2_ipaddr, 'ipv4': j2_ipv4, 'ipv6': j2_ipv6, - 'hwaddr': j2_hwaddr, - 'fail': j2_fail + 'hwaddr': j2_hwaddr } """ diff --git a/netsim/utils/templates.py b/netsim/utils/templates.py index 17fd6ceeac..c7a09d69e7 100644 --- a/netsim/utils/templates.py +++ b/netsim/utils/templates.py @@ -27,6 +27,9 @@ def add_j2_filters(ENV: Environment) -> None: for fname,fcode in filters.BUILTIN_FILTERS.items(): # Do the same for ansible.builtin filters we use ENV.filters[fname] = fcode ENV.filters[f'ansible.builtin.{fname}'] = fcode + + # Add fail() as a global function for template validation + ENV.globals['fail'] = filters.j2_fail """ Render a Jinja2 template From 1c2e54ebd9ddaa25aaa6f9fdba14f4bb9bfa9117 Mon Sep 17 00:00:00 2001 From: Ivan Pepelnjak Date: Fri, 9 Jan 2026 11:40:28 +0100 Subject: [PATCH 11/11] Implementation tweaks * Documentation * Remove the two levels of indirection in config deployment * Wait for "save ns config" to succeed * Simplify Dockerfile, create everything in a single RUN step * Reduce the wait time -- repeat "add system user" until it works --- docs/labs/clab.md | 5 +- docs/platforms.md | 5 ++ .../ansible/tasks/deploy-config/netscaler.yml | 15 ++-- netsim/daemons/netscaler/Dockerfile.j2 | 85 ++++++++++--------- netsim/devices/netscaler.yml | 5 +- 5 files changed, 62 insertions(+), 53 deletions(-) diff --git a/docs/labs/clab.md b/docs/labs/clab.md index dba80bf692..9eac54584c 100644 --- a/docs/labs/clab.md +++ b/docs/labs/clab.md @@ -40,6 +40,7 @@ Lab topology file created by **[netlab up](../netlab/up.md)** or **[netlab creat | Cisco IOSv | vrnetlab/cisco_vios:15.9.3 | | Cisco IOS XRd | ios-xr/xrd-control-plane:7.11.1 | | Cisco Nexus OS | vrnetlab/vr-n9kv:9.3.8 | +| Citrix Netscaler | netlab/netscaler:latest | | Cumulus VX | networkop/cx:4.4.0 | | Cumulus VX with NVUE | networkop/cx:5.0.1 | | Dell OS10 | vrnetlab/vr-ftosv | @@ -58,8 +59,8 @@ Lab topology file created by **[netlab up](../netlab/up.md)** or **[netlab creat | VyOS | ghcr.io/sysoleg/vyos-container | * Cumulus VX, FRR, Linux, Nokia SR Linux, and VyOS images are automatically downloaded from public container registries. -* Build the BIRD and dnsmasq images with the **netlab clab build** command. -* Arista cEOS image has to be [downloaded and installed manually](ceos.md). +* Build the BIRD, dnsmasq, and Netscaler images with the **netlab clab build** command. +* The Arista cEOS image has to be [downloaded and installed manually](ceos.md). * Nokia SR OS and SR-SIM container images require a license; see also [vrnetlab instructions](https://containerlab.srlinux.dev/manual/vrnetlab/). * Follow Cisco's documentation to install the IOS XRd container, making sure the container image name matches the one _netlab_ uses (alternatively, [change the default image name](default-device-image) for the IOS XRd container). * Cisco 8000v containerlab image (once you manage to get it) has to be [installed](https://containerlab.dev/manual/kinds/c8000/#getting-cisco-8000-containerlab-docker-images) with the **docker image load** command. diff --git a/docs/platforms.md b/docs/platforms.md index d537fe1c3f..8e24564eb2 100644 --- a/docs/platforms.md +++ b/docs/platforms.md @@ -39,6 +39,7 @@ | vJunos-router [❗](caveats-vjunos-router) | vjunos-router | full | | Mikrotik RouterOS 6 (CHR) [❗](caveats-routeros6) | routeros | end of life | | Mikrotik RouterOS 7 (CHR) [❗](caveats-routeros7) | routeros7 | minimal | +| Netscaler CPX | netscaler | minimal | | Nokia SR Linux [❗](caveats-srlinux) | srlinux | full | | Nokia SR OS [❗](caveats-sros) | sros | best effort[^SROSBE] | | Nokia SR-SIM [❗](caveats-srsim) | srsim | full | @@ -130,6 +131,7 @@ You cannot use all supported network devices with all virtualization providers. | vJunos-switch | ❌ | ❌ | ✅[❗](clab-vrnetlab) | | vJunos-router | ❌ | ❌ | ✅[❗](clab-vrnetlab) | | Mikrotik RouterOS 6 | ✅ | ❌ | ❌ | | Mikrotik RouterOS 7 | [✅](build-chr7) | ❌ | ❌ | +| Netscaler CPX | ❌ | ❌ | ✅ | | Nokia SR Linux | ❌ | ❌ | ✅ | | Nokia SR OS | ❌ | ❌ | ✅ | | Nokia SR-SIM | ❌ | ❌ | ✅ | @@ -193,6 +195,7 @@ Ansible playbooks included with **netlab** can deploy and collect device configu | Junos[^Junos] | ✅ | ✅ | | Mikrotik RouterOS 6 | ✅ | ✅ | | Mikrotik RouterOS 7 | ✅ | ✅ | +| Netscaler CPX | ✅ | ❌ | | Nokia SR Linux | ✅ | ✅ | | Nokia SR OS[^SROS] | ✅ | ✅ | | OpenBSD | ✅ | ❌ | @@ -229,6 +232,7 @@ The following system-wide features are configured on supported network operating | Junos[^Junos] | ✅ | ❌ | ✅ | ✅ | ✅ | | Mikrotik RouterOS 6 | ✅ | ✅ | ✅[❗](caveats-routeros6) | ✅ | ✅ | | Mikrotik RouterOS 7 | ✅ | ✅ | ✅[❗](caveats-routeros7) | ✅ | ✅ | +| Netscaler CPX | ✅ | ❌ | ❌ | ❌ | ❌ | | Nokia SR Linux | ✅ | ✅ | ✅ | ✅ | ✅ | | Nokia SR OS[^SROS] | ✅ | ✅ | ✅ | ✅ | ✅ | | OpenBSD | ✅ | ✅ | ❌ | ✅ | ✅ | @@ -284,6 +288,7 @@ The following interface addresses are supported on various platforms: | Junos[^Junos] | ✅ | ✅ | ✅ | ❌ | | Mikrotik RouterOS 6 | ✅ | ✅ | ❌ | ❌ | | Mikrotik RouterOS 7 | ✅ | ✅ | ❌ | ❌ | +| Netscaler CPX | ✅ | ❌ | ❌ | ❌ | | Nokia SR Linux | ✅ | ✅ | ❌ | ❌ | | Nokia SR OS[^SROS] | ✅ | ✅ | ✅ | ❌ | | OpenBSD | ✅ | ✅ | ❌ | ❌ | diff --git a/netsim/ansible/tasks/deploy-config/netscaler.yml b/netsim/ansible/tasks/deploy-config/netscaler.yml index bd72ccf64b..52827b828e 100644 --- a/netsim/ansible/tasks/deploy-config/netscaler.yml +++ b/netsim/ansible/tasks/deploy-config/netscaler.yml @@ -3,16 +3,17 @@ - name: "Netscaler: deploying {{ netsim_action }} from {{ config_template }}" ansible.builtin.raw: | - cat > /tmp/deploy.sh << 'NETSCALER_EOF' - #!/bin/bash + AUTHENTICATE=127.0.0.1:{{ ansible_user }}:{{ ansible_ssh_pass }} while IFS= read -r line; do - [ -n "$line" ] && nscli -U 127.0.0.1:{{ ansible_user }}:{{ ansible_ssh_pass }} "$line" || true + [ -n "$line" ] && nscli -U "$AUTHENTICATE" "$line" || true done << 'CONFIG_EOF' {{ deployed_config }} CONFIG_EOF - nscli -U 127.0.0.1:{{ ansible_user }}:{{ ansible_ssh_pass }} "save ns config" - NETSCALER_EOF - chmod +x /tmp/deploy.sh - sh /tmp/deploy.sh + + if ! nscli -U "$AUTHENTICATE" "save ns config"; then + echo "Save failed waiting..." + sleep 5 + nscli -U "$AUTHENTICATE" "save ns config" + fi when: not ansible_check_mode tags: [ print_action, always ] diff --git a/netsim/daemons/netscaler/Dockerfile.j2 b/netsim/daemons/netscaler/Dockerfile.j2 index d6297af47b..557f3f1e82 100644 --- a/netsim/daemons/netscaler/Dockerfile.j2 +++ b/netsim/daemons/netscaler/Dockerfile.j2 @@ -1,55 +1,56 @@ -{% set _user = defaults.devices.netscaler.clab.group_vars.ansible_user %} -{% set _password = defaults.devices.netscaler.clab.group_vars.ansible_ssh_pass %} -{% if not _user %}{{ fail('ansible_user must be defined in devices.netscaler.clab.group_vars') }}{% endif %} -{% if not _password %}{{ fail('ansible_ssh_pass must be defined in devices.netscaler.clab.group_vars') }}{% endif %} FROM quay.io/netscaler/netscaler-cpx:13.1-60.29 LABEL description="Citrix Netscaler ADC CPX for containerlab" ENV EULA=yes -# Build arguments for clab user credentials (using netlab device defaults) -ARG CLAB_USER={{ _user }} -ARG CLAB_PASSWORD={{ _password }} -ENV CLAB_USER=${CLAB_USER} -ENV CLAB_PASSWORD=${CLAB_PASSWORD} - -# Create user on the system for SSH access -RUN useradd -m ${CLAB_USER} && echo "${CLAB_USER}:${CLAB_PASSWORD}" | chpasswd - -# Create required directories and permissions -RUN mkdir -p /var/nstmp && \ - chmod 777 /var/nstmp - -# Provision script - give clab user superuser privileges -RUN (echo '#!/bin/bash'; \ - echo "cli_script.sh \"add system user ${CLAB_USER} ${CLAB_PASSWORD} -promptString '%u@%h'\""; \ - echo "cli_script.sh \"bind system user ${CLAB_USER} superuser 0\""; \ - echo 'cli_script.sh "save ns config"') > /provision.sh && \ - chmod +x /provision.sh - -# nscli wrapper - allows clab user to enter nscli, already logged in -RUN (echo '#!/bin/bash'; \ - echo "exec nscli -U 127.0.0.1:${CLAB_USER}:${CLAB_PASSWORD}") > /usr/local/bin/nscli-wrapper && \ - chmod +x /usr/local/bin/nscli-wrapper - -# Set nscli wrapper as default shell for user -RUN usermod -s /usr/local/bin/nscli-wrapper ${CLAB_USER} +# Setup the netlab container environment +{% set _user = defaults.devices.netscaler.clab.group_vars.ansible_user %} +{% set _password = defaults.devices.netscaler.clab.group_vars.ansible_ssh_pass %} +{% if not _user %}{{ fail('ansible_user must be defined in devices.netscaler.clab.group_vars') }}{% endif %} +{% if not _password %}{{ fail('ansible_ssh_pass must be defined in devices.netscaler.clab.group_vars') }}{% endif %} +RUN </usr/local/bin/nscli-wrapper +#!/bin/bash +exec nscli -U 127.0.0.1:{{ _user }}:{{ _password }} +WRAPPER +chmod +x /usr/local/bin/nscli-wrapper +cat >/entrypoint.sh </tmp/result.txt + cat /tmp/result.txt + if ! grep -q "ERROR: Connection failed" /tmp/result.txt; then + echo "User configured" + break + fi + echo "Provision: waiting for another 5 seconds" + sleep 5 + done + cli_script.sh "bind system user {{ _user }} superuser 0" + cli_script.sh "save ns config" + touch /initialized +fi +wait +ENTRYPOINT +chmod +x /entrypoint.sh +BUILD # Entrypoint - runs startup and also provisioning if container is not already initialized # the timing of 120 seconds is required to ensure the netscaler is fully started before creating the user # when tested with 14.1, 120 was not enough, it might need to be increased to 180 seconds for future versions -RUN (echo '#!/bin/bash'; \ - echo 'if [ ! -f /initialized ]; then'; \ - echo ' /var/netscaler/bins/docker_startup.sh &'; \ - echo ' sleep 120'; \ - echo ' /provision.sh'; \ - echo ' touch /initialized'; \ - echo 'else'; \ - echo ' /var/netscaler/bins/docker_startup.sh'; \ - echo 'fi'; \ - echo 'wait') > /entrypoint.sh && \ - chmod +x /entrypoint.sh ENTRYPOINT ["/entrypoint.sh"] EXPOSE 22 9080 9443 diff --git a/netsim/devices/netscaler.yml b/netsim/devices/netscaler.yml index 6a4fcfeadd..8c51f54928 100644 --- a/netsim/devices/netscaler.yml +++ b/netsim/devices/netscaler.yml @@ -13,8 +13,7 @@ features: ipv4: mtu: 1500 clab: - build: https://netlab.tools/labs/netscaler/ - # image: netlab/netscaler + image: netlab/netscaler node: kind: linux # kind not yet available in Containerlab interface: @@ -24,6 +23,8 @@ clab: ansible_ssh_pass: clab@123 ansible_connection: docker netlab_show_command: [ nscli, -U, '127.0.0.1:clab:clab@123', 'show $@' ] +# netlab_ready: [ ssh ] +# netlab_check_command: who external: image: none