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 new file mode 100644 index 0000000000..52827b828e --- /dev/null +++ b/netsim/ansible/tasks/deploy-config/netscaler.yml @@ -0,0 +1,19 @@ +- ansible.builtin.set_fact: + deployed_config: "{{ lookup('template', config_template) }}" + +- name: "Netscaler: deploying {{ netsim_action }} from {{ config_template }}" + ansible.builtin.raw: | + AUTHENTICATE=127.0.0.1:{{ ansible_user }}:{{ ansible_ssh_pass }} + while IFS= read -r line; do + [ -n "$line" ] && nscli -U "$AUTHENTICATE" "$line" || true + done << 'CONFIG_EOF' + {{ deployed_config }} + CONFIG_EOF + + 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/ansible/tasks/readiness-check/netscaler.yml b/netsim/ansible/tasks/readiness-check/netscaler.yml new file mode 100644 index 0000000000..e64f552fa7 --- /dev/null +++ b/netsim/ansible/tasks/readiness-check/netscaler.yml @@ -0,0 +1,17 @@ +--- +# +# Netscaler readiness check - wait for nscli to be available +# +- name: Check {{ netlab_device_type|default(inventory_hostname) }} nscli readiness + 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 + retries: "{{ netlab_check_retries | default(60) }}" + delay: "{{ netlab_check_delay | default(5) }}" + failed_when: False + +- name: Confirm {{ inventory_hostname }} is ready + 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 new file mode 100644 index 0000000000..191b0c21f5 --- /dev/null +++ b/netsim/ansible/templates/initial/netscaler.j2 @@ -0,0 +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 +{% if l.ipv4 is defined %} +{% 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 }} +bind vlan {{ vlan_id }} -IPAddress {{ l.ipv4|ansible.utils.ipaddr('address') }} {{ l.ipv4|ansible.utils.ipaddr('netmask') }} +{% endif %} +{% endfor %} diff --git a/netsim/cli/clab_actions/build.py b/netsim/cli/clab_actions/build.py index aa64ca9c7d..3ac5d21c1b 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,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) - df_dict[daemon+ext] = 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 @@ -54,17 +57,47 @@ def get_description(dfname: str) -> str: for line in df_lines: if not line.startswith('LABEL'): continue - if not 'description=' in line: continue - return line.split('description=')[1].replace('"','') - + except: return '-- failed --' 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)}") + + # Load topology defaults to get device credentials + try: + defaults = _read.system_defaults().defaults + except Exception as ex: + log.fatal(f'Could not load system defaults: {str(ex)}', module='build') + + # Render template (fail() is available as a standard Jinja2 global function) + try: + 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') + + strings.print_colored_text('[RENDERED] ','green',None) + print(f"Template rendered to temporary Dockerfile") + + return os.path.join(tmp_dir, 'Dockerfile') + def build_image(image: str, tag: typing.Optional[str]) -> None: if tag is None or not tag: tag = f'netlab/{image}:latest' @@ -93,8 +126,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 +149,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 diff --git a/netsim/daemons/netscaler/Dockerfile b/netsim/daemons/netscaler/Dockerfile deleted file mode 100644 index 4e1a56d29d..0000000000 --- a/netsim/daemons/netscaler/Dockerfile +++ /dev/null @@ -1,49 +0,0 @@ -FROM quay.io/netscaler/netscaler-cpx:13.1-60.29 - -ENV EULA=yes - -# Build arguments for clab user credentials -ARG CLAB_USER=clab -ARG CLAB_PASSWORD=clab@123 -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} - -# 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 -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/daemons/netscaler/Dockerfile.j2 b/netsim/daemons/netscaler/Dockerfile.j2 new file mode 100644 index 0000000000..557f3f1e82 --- /dev/null +++ b/netsim/daemons/netscaler/Dockerfile.j2 @@ -0,0 +1,56 @@ +FROM quay.io/netscaler/netscaler-cpx:13.1-60.29 + +LABEL description="Citrix Netscaler ADC CPX for containerlab" + +ENV EULA=yes + +# 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 + +ENTRYPOINT ["/entrypoint.sh"] +EXPOSE 22 9080 9443 diff --git a/netsim/devices/netscaler.yml b/netsim/devices/netscaler.yml new file mode 100644 index 0000000000..8c51f54928 --- /dev/null +++ b/netsim/devices/netscaler.yml @@ -0,0 +1,32 @@ +--- +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: + 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 $@' ] +# netlab_ready: [ ssh ] +# netlab_check_command: who + +external: + image: none + +graphite.icon: server diff --git a/netsim/utils/filters.py b/netsim/utils/filters.py index eb8ca1df68..619bad8e54 100644 --- a/netsim/utils/filters.py +++ b/netsim/utils/filters.py @@ -102,6 +102,15 @@ 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(msg: str) -> None: + """ + Jinja2 global function to fail template rendering with a custom error message. + Use in templates: {{ fail('Error message') }} + """ + raise ValueError(msg) + class j2_Undefined(StrictUndefined): """ Mimics Ansible's undefined variable handling in Jinja2 templates. 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