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
5 changes: 3 additions & 2 deletions docs/labs/clab.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions docs/platforms.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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 | ❌ | ❌ | ✅ |
Expand Down Expand Up @@ -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 | ✅ | ❌ |
Expand Down Expand Up @@ -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 | ✅ | ✅ | ❌ | ✅ | ✅ |
Expand Down Expand Up @@ -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 | ✅ | ✅ | ❌ | ❌ |
Expand Down
19 changes: 19 additions & 0 deletions netsim/ansible/tasks/deploy-config/netscaler.yml
Original file line number Diff line number Diff line change
@@ -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 ]
17 changes: 17 additions & 0 deletions netsim/ansible/tasks/readiness-check/netscaler.yml
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions netsim/ansible/templates/initial/netscaler.j2
Original file line number Diff line number Diff line change
@@ -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 %}
53 changes: 46 additions & 7 deletions netsim/cli/clab_actions/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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

Expand All @@ -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'
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
49 changes: 0 additions & 49 deletions netsim/daemons/netscaler/Dockerfile

This file was deleted.

56 changes: 56 additions & 0 deletions netsim/daemons/netscaler/Dockerfile.j2
Original file line number Diff line number Diff line change
@@ -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 <<BUILD
# Make working directory
#
mkdir -p /var/nstmp && chmod 777 /var/nstmp
#
# Add clab user
#
useradd -m {{ _user }} && echo "{{ _user }}:{{ _password }}" | chpasswd
#
# Create bash scripts
#
cat <<WRAPPER >/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 <<ENTRYPOINT
#!/bin/bash
/var/netscaler/bins/docker_startup.sh &
if [ ! -f /initialized ]; then
while true; do
cli_script.sh "add system user {{ _user }} {{ _password }} -promptString '%u@%h'" >/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
32 changes: 32 additions & 0 deletions netsim/devices/netscaler.yml
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions netsim/utils/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions netsim/utils/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down