We’ve been playing recently with automating a lot of the Juniper devices we’ve got to deploy, the latest fun being the Juniper equivalent with PXE: ZTP.

The idea being, we want to update, and throw a base level config but do this as programatically as possible.

Update – 2021-10-08
Note – on QFX you cannot use the cluster (vme) interface in a routing-instance other than inet0. Hence if using management-instance (management vrf) then you will need to use em0.0 – this means the mac address will be different AFTER you switch to management-instance – one of the many reasons I prefer to use DHCP for a base image upgrade then static in any base configs.

What is ZTP

To quote Juniper here

Zero Touch Provisioning (ZTP) allows you to provision new Juniper Networks devices in your network automatically, with minimal manual intervention. You can use either management ports or network ports, depending on your device, to connect to the network. When you physically connect a device to the network and boot it with a default factory configuration, the device upgrades (or downgrades) the Junos OS release and autoinstalls a configuration file from the network. The configuration file can be a Junos OS configuration or a script. Using scripts, you can create device-specific configuration files and perform HTTP request operations to web servers to download specific configuration files or Junos OS releases.


How it works

As per VOIP, we have a series of DHCP options to consider, we define these in our DHCP scopes but they can be summarised via an example, which should be pretty self explanatory. ISC DHCPd allows us to define these as specific options, hence:

option space juniper;
option juniper.image-file-name code 0 = text;
option juniper.config-file-name code 1 = text;
option juniper.image-file-type code 2 = text;
option juniper.transfer-mode code 3 = text;
option juniper.alt-image-file-name code 4 = text;
option juniper.http-port code 5 = text;

In Practise

Now, ideally we want all devices of a certain type to share the same OS, so we can do this with using the vendor id to identify a model (note the 22 is the character length of the string here for matching)

class "qfx-5100t" {
match if substring (option vendor-class-identifier,0,22) = "Juniper-qfx5100-48t-6q";
vendor-option-space juniper;
option juniper.image-file-name "/dist/images/jinstall-host-qfx-5-20.2R3.9-signed.tgz";
option juniper.transfer-mode "ftp";

We’re going to use anonymous FTP here, hence the image-file-name is relative to the FTP root, here we’ve copied the jinstall image to dist/images which sits in /srv/ftp/, the default for Ubuntu anonymous FTP

Now for individual hosts, we ideally want to match on the MAC address of vme0, so lets define a host (note: mac address for us, appears to be mac address of the device on the device sticker, +1)

host device1-dc1-ncl {
    hardware ethernet 88:90:09:39:e2:c1;
    option routers;
    option tftp-server-name "";
    option host-name "device1.dc1.ncl1";


Once we’ve restarted dhcp, what will this do – well, it’ll assign the device, and automatically upgrade to the install image we’ve specified under the device class.

Automating with ansible

Now, we’re going to need 3 things to make the magic work, assuming we’ve defined the device classes already – these being

  • hostname
  • mac address
  • an ip

The first 2, we can ask for, the third is more difficult. There are many ways to achieve this, but we’re going to use a json file – you could use netbox, sqlite but this is quick.

Generating the IP DB

This uses a loop to generate a dict for a range of ip’s from ->, we’ve used the last prefix address as the svi, and jumps this as json.

import json

ip_db = {}
for x in range (124):
    ip = "10.163.36." + str(x+1)
    ip_db[ip] = { }

with open('ip_db.json', 'w') as outfile:
    json.dump(ip_db, outfile)

assigning an ip

This script is even more simple, take a single argument (argv[1]) read in the json, look for the first entry without a ‘hostname’ dict item, add this to the dict, save back to the json file, and print the chosen ipv4. Its crude, full of race conditions but this is a PoC 🙂

import json
import sys

hostname_var = sys.argv[1]
chosen_one_var = ""

f = open("ip_db.json")
ip_db = json.load(f)

for ip in ip_db.keys():
    if "hostname" not in ip_db[ip].keys():
        ip_db[ip]['hostname'] = hostname_var
        chosen_one_var = ip

with open('ip_db.json', 'w') as outfile:
    json.dump(ip_db, outfile)


The playbook

User Input

We’ll use the playbook vars prompt to obtain the hostname, mac address .. and a select type item for the device type, we don’t use this, but we might later on, so worth doing.

     - name: hostname
       prompt: Please Enter the device hostname
       private: no
     - name: mac_address
       prompt: Please Enter the Host mac address
       private: no
     - name: devtype
       prompt: Device Type |
          {% for device in device_types %}
          {{ loop.index }} {{ device }}
          {% endfor %}
       - Juniper-qfx5100-48t-6q
       - Juniper-qfx5110-32q

we’re now onto tasks, so firstly, lets use Jinja to generate the required lines for dhcpd, ansibles excellent blockinfile to add the block, we won’t restart dhcpd yet … as we’ve a config to generate too ..

     - name: Assign Next Free IP ..
       ansible.builtin.command: python3 ./assign_ip.py {{ hostname }}
       register: ip_address
       run_once: true
     - name: generating dhcpd.conf entry ...
         dhcpd_lines: "{{ lookup('template', 'templates/dhcpd-host.j2') }}"
       run_once: true
     - name: adding lines to dhcpd
         path: /etc/dhcp/dhcpd.conf
         block: |
           {{ dhcpd_lines }}
       run_once: true

but what does our dhcpd template look like, well, something like this:

host {{ hostname | replace(".","-") }} {
    hardware ethernet {{ mac_address }};
    fixed-address {{ ip_address.stdout }};
    option routers;
    option tftp-server-name "";
    option host-name "{{ hostname }}";
    option juniper.config-file-name "cfgs/{{ hostname }}.cfg"

Couple of things here, firstly we hyphenate the hostname, no reason to do this I just prefer it, we already know the routers/ftp server ip, so complete the blanks.

you’ll notice we’ve included a config file hence lets complete the playbook to add in some baseline config for that file (again using Jinja2) and also use pyats/genie to find the switchport (which will be in a null vlan, so will need switching)

     - name: generating baseline config
         src: templates/junos_baseline.j2
         dest: /srv/ftp/cfgs/{{ hostname }}.cfg
       run_once: true
     - name: restart dhcpd
          name: isc-dhcp-server.service
          state: restarted
       run_once: true
     - name: getting mac address table from dist_switch
            - show mac address-table
       register: mac_table_result
     - name: Set fact with Genie filter plugin
         pyats_mac_table: "{{ mac_table_result['stdout'][0] | parse_genie(command='show mac address-table', os='ios') }}"
     - name: hunting for the mac
         was_found: |
                 {% for vlan in pyats_mac_table.mac_table.vlans %}
                 {% set wanted = mac_address.replace(":","") %}
                 {% for address in pyats_mac_table.mac_table.vlans[vlan].mac_addresses %}
                 {% set found = address.replace(".","") %}
                 {% if found == wanted %}
                 {% for interfacename in pyats_mac_table.mac_table.vlans[vlan].mac_addresses[address].interfaces %}
                 Found {{ pyats_mac_table.mac_table.vlans[vlan].mac_addresses[address].interfaces[interfacename].interface }}
                 {% endfor %}
                 {% endif %}
                 {% endfor %}
                 {% endfor %}

So what is this doing ? well, firstly we use jinja2 to generate a template config, may blog more on how we can generate full configs from netbox, but for now, lets keep the template light

set system management-instance
set routing-instances mgmt_junos description mgmt_junos
set routing-instances mgmt_junos routing-options static route next-hop
set system host-name {{ hostname }}
set system login user netconf class super-user authentication plain-text-password-value Juniper
set system root-authentication plain-text-password-value Juniper
set system services netconf ssh
set system services ssh root-login allow
delete interfaces vme unit 0 family inet dhcp
delete interfaces em0 unit 0 family inet dhcp
set interfaces em0 unit 0 family inet address {{ ip_address.stdout }}/25

note, you can use an hash generator here, or the hidden command plain-text-password-value to provide an unencrypted password…

We then run the ios-command show mac address table, then genie to parse this to json.

This is where ansible starts to fall down a little, and we have to use jinja to loop through the json to extract the interface, but we do …

We should then take that interface and run addition ios commands / config to set the switchport, but for now we stop there… output below with this running:

(venv) root@ansible:/home/russellc/ansible/ztp# ansible-playbook -i hosts generate_ztp.yml
Please Enter the device hostname: device-2
Please Enter the Host mac address: c8:fe:6a:ad:fe:02
Device Type |  1 Juniper-qfx5100-48t-6q  2 Juniper-qfx5110-32q :

PLAY [Juniper ZTP Generator] ******************************************************************************************************************************

TASK [Assign Next Free IP ..] *****************************************************************************************************************************
changed: [dist_switch]

TASK [generating dhcpd.conf entry ...] ********************************************************************************************************************
ok: [dist_switch]

TASK [adding lines to dhcpd] ******************************************************************************************************************************
changed: [dist_switch]

TASK [restart dhcpd] **************************************************************************************************************************************
changed: [dist_switch]

TASK [generating baseline config] *************************************************************************************************************************
changed: [dist_switch]

TASK [getting mac address table from dist_switch] *********************************************************************************************************
ok: [dist_switch]

TASK [Set fact with Genie filter plugin] ******************************************************************************************************************
ok: [dist_switch]

TASK [hunting for the mac] ********************************************************************************************************************************
ok: [dist_switch]

TASK [display] ********************************************************************************************************************************************
ok: [dist_switch] => {
    "msg": "device-2 / c8:fe:6a:ad:fe:02 / Juniper-qfx5110-32q"

TASK [display 2] ******************************************************************************************************************************************
ok: [dist_switch] => {
    "was_found": "Found GigabitEthernet1/0/13\n"

PLAY RECAP ************************************************************************************************************************************************
dist_switch                : ok=10   changed=4    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

Hope this helps someone!


No responses yet

Leave a Reply

Your email address will not be published. Required fields are marked *

About This Site

This may be a good place to introduce yourself and your site or include some credits.