Intro

While preparing for my Devnet DevOps exam and now preparing for the DevCor, I realized is a great opportunity to create mini-projects from the topics I'm learning and at the end have a good network automation.

I'm aware of what Ansible is and what it does from some time ago, but I never played with it, as I prefer to go directly with Python. However, I see the worth to invest in Ansible since is another tool and sometimes you just want something that it works.

The instructions you will see below are not exclusive for Ansible. The procedure is the same if you intended to dockerized any application.

TL;DR

I get it, sometimes you are short on time and you are looking for something quickly, here is the juicy stuff.

  • Github source files are here
    • You may see reference to my docker image from Docker Hub, rather than the base image defined here. In the end, I want to build once and re-use many.
    • Update paths if you are not executing from the root dir.
    • This is a living project, see the main branch for the latest source code. All links in the post are for the "Ansible in a Container" release.
  • My Ansible image is here on docker Hub
  • If you don't have network devices you can reserve a free sandbox with Cisco DevNet. Remember is Free!

To clone this specifc project do:

Clone this specific release
git clone -b v0.1.0 --single-branch https://github.com/jillesca/network-automation.git

Why?

First, I don't want to maintain a VM just for Ansible or install it on my laptop and deal with Python versions and virtual environments. I don't see the point, when you can use a container just for Ansible.

But, besides the first point, the main reason is the value you get when you use Ansible in a CI/CD pipeline. Having a container in the pipeline, will make it easier to implement and maintain since the containers are used only when needed.

A good side efect, is Consistency. Once you built a container, it will always work. Of course, you need to follow best practices when defining what is installed, and when to built or use an image.

In basic terms:

  • Be super specific on what needs to be installed, e.g. package versions
  • Use this container as a base image
  • Use this base image for deployments.

Create an Ansible Image

The first step I did, was to look for an official Ansible image, there is one, but the last update was 5 years ago. So I decided to build my own.

Since Ansible is based on Python, I decided to use Python as a based image. I also choose an Alpine variant, since is the smallest size. Size matters for the pipeline, since the container will be created and destroyed everytime we do changes with Ansible, therefore the image is transfered, ergo is good to have a small image. This could also be optimized.

Create a DockerFile

A dockerfile is basically a plain text file with instructions docker understands. For a full explanation on docker files check the dockerfile reference.

The dockerfile I used is the following:

docker
# syntax=docker/dockerfile:1
FROM python:alpine3.16 AS base
COPY ./docker/ansible/requirements.txt .
RUN pip3 install -r requirements.txt
WORKDIR home

What it says is:

  • First we define FROM which image we want to build. In this case python:alpine3.16.
    • You case see here, I'm not using python:latest since I don't know which image could be, and newer images could break dependencies down the road.
  • Second, COPY a file inside the container.
    • Depending on which directory you are executing the docker build command, you need to adjust the first path, in this case ./docker/ansible/requirements.txt and if you pay attention you can see a . (dot) at the end
  • Third, RUN pip3 to install some dependencies
  • Finally, we change directory with WORKDIR

Like I commented before, is important to be super specific about which packages are installed in out image. For this I'm using a requirements.txt file. Filename could be anything, but this is the common convention for installing dependencies.

requirements.txt
ansible==6.5.0
ansible-core==2.13.5
certifi==2022.9.24
cffi==1.15.1
charset-normalizer==2.1.1
cryptography==38.0.1
idna==3.4
Jinja2==3.1.2
MarkupSafe==2.1.1
packaging==21.3
pycparser==2.21
pyparsing==3.0.9
PyYAML==6.0
requests==2.28.1
resolvelib==0.8.1
urllib3==1.26.12
paramiko==2.11.0
note
After publishing this post, I created a new version where openssh-client is added to forward ssh keys for a bastion/jump host. the new image is jillesca/ansible:version1.1 in my docker hub.

If you are wondering how I got those requirements, basically, I started a python:alpine3.16 container, installed Ansible and saw which dependecies were installed (pip freeze). This was a manual process and is a one-time, unless you need to update the image.

Build a Base Image

Having those two files is enough to start building our image. To build your image use the docker build command.

All commands used assume they are executed from the root directory. This is key, since docker and docker-compose need to know the path to the files or mounts specified, if you are not in the root directory, you need to update this part.

docker
docker build --file docker/ansible/base-ansible.dockerfile --tag ansible:version1.0 .

Then to run the container, use docker run.

docker
docker run -dt --name ansible ansible:version1.0

Finally, to enter the container, open an interactive session on /bin/sh. From there you can verify the Ansible installation.

docker
docker exec -it ansible /bin/sh
/home # ansible --version
ansible [core 2.13.5]
  config file = /home/ansible.cfg
  configured module search path = ['/root/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = /usr/local/lib/python3.10/site-packages/ansible
  ansible collection location = /root/.ansible/collections:/usr/share/ansible/collections
  executable location = /usr/local/bin/ansible
  python version = 3.10.8 (main, Oct 13 2022, 23:21:19) [GCC 11.2.1 20220219]
  jinja version = 3.1.2
  libyaml = False
/home #

Note, if you want to upload/push your images to your docker registry, update the tag so docker knows where to push it. Don't forget to login into your registry from docker first.

Developing with the Base Image

Using a container directly is very useful for automation, but no much for developing with it. An super important consideration when working with containers is the following:

A container is Ephemeral, therefore is not intended by design to do changes on the container at runtime, since those changes will be lost once the container is destroyed

If you need to do changes, perform the changes on the repository, then you build a new image and this image will have your changes. Sounds simple, right?. The truth is that for developing and testing is not efficient.

Docker Compose

This is where docker-compose comes to the help. This is a tool already included with docker that helps you create services.

In our case, compose helps us to create a volume where we can mount our Ansible files inside the docker container. This means that changes we do on our repo, will be automatically sync inside the container, making developing and testing easier.

For this project, I created the following directory structure Inside my repository.

╰─ tree
.
     ...
├── ansible
│    ...
└── docker
    ├── ansible
    │   ├── base-ansible.dockerfile
    │   └── requirements.txt
    └── ansible.docker-compose.yml

You can see, I separated docker related files from Ansible files, so I can easily distinguish between services when working with a pipeline and testing.

Now, first create a docker-compose file.

docker-compose.yml
version: "3.8"

services:
  ansible:
    image: ansible:version1.0
    container_name: ansible
    tty: true
    volumes:
      - ./../ansible:/home

Then we tell docker-compose to create a container and use a volume to mount our Ansible files inside the container. This is the key line: ./../ansible:/home, where we are telling compose where our Ansible files are, and where to mount them, in this case in the home directory of the container.

Then we can bring the container to life.

docker-compose
docker-compose -f ./docker/ansible.docker-compose.yml up -d

Finally we enter the container, from here, we can execute our Ansible commands.

docker
docker exec -it ansible /bin/sh

As a side note, you can build directly in compose. If you go to the docker-compose file at Github, you will see I commented a build option, with this, you can build directly your image.

However I recommend, build your image separately, push your image to your registry, e.g. Docker Hub and consume it from it. If you go to the Github docker-compose you will see I'm using a image I created and pushed to Docker Hub

docker-compose.yml
image: jillesca/ansible:version1.0

Ansible Configuration

Before we can verify our work, is important to create an Ansible configuration, so we can use for testing.

Below is the base Ansible directory I use.

ansible
╰─ tree
.
...
├── ansible
│   ├── ansible.cfg
│   ├── group_vars
│   │   └── all.yml
│   ├── host_vars
│   │   ├── dist-rtr01.yml
│   │   └── dist-rtr02.yml
│   ├── inventory.yml
│   └── snmp_test.yml
└── docker

And below you can see how my files look like. You can also check them on Github. If you want to see the latest code, see the main branch.

inventory.yml
---
dist:
  hosts:
    dist-rtr01:
      ansible_host: 10.10.20.176
    dist-rtr02:
      ansible_host: 10.10.20.175
ansible.cfg
[defaults]
host_key_checking = False
group_vars/all.yml
---
ansible_ssh_pass: cisco
ansible_user: cisco
ansible_network_os: ios

snmp_community: cisco_test
snmp_location: Lisbon_HQ
snmp_contact: Jesus_Illescas
host_vars/dist-rtr01.yml
---
snmp_location_floor: floor_2
host_vars/dist-rtr02.yml
---
snmp_location_floor: floor_1
snmp_test.yml
---
- name: MAKE CONFIG CHANGES USING GROUP_VARS
  hosts: all
  connection: network_cli
  gather_facts: no

  tasks:
    - name: CONFIGURE SNMP COMMUNITY USING VARIABLES STORED IN GROUP_VARS
      ios_config:
        commands:
          - snmp-server community {{ snmp_community }} RO
          - snmp-server location {{ snmp_location }} {{ snmp_location_floor }}
          - snmp-server contact {{ snmp_contact }}

Verification

After all of this, is time to verify our Ansible container is working as expected.

The first requirement you need is, IP Connectivity from the host where the container is being executed to your network devices. I'm my case I'm using the sandbox through a VPN and I can SSH the devices directly.

Now, let's see what is on our devices

dist-rtr01
dist-rtr01#show run | i snmp
dist-rtr01#
dist-rtr02
dist-rtr02#show run | i snmp
dist-rtr02#

Access the container and review what is inside.

docker
docker exec -it ansible /bin/sh
/home # ls -l
total 12
-rw-r--r--    1 root     root           266 Oct 23 13:46 ansible.cfg
drwxr-xr-x    3 root     root            96 Oct 23 13:46 group_vars
drwxr-xr-x    4 root     root           128 Oct 23 13:46 host_vars
-rw-r--r--    1 root     root           411 Oct 23 13:46 inventory.yml
-rw-r--r--    1 root     root           415 Oct 23 13:46 snmp_test.yml
/home #

From the container, use ansible-playbook to run the playbook snmp_test.yml

ansible
/home # ansible-playbook -i inventory.yml snmp_test.yml -v
Using /home/ansible.cfg as config file

PLAY [MAKE CONFIG CHANGES USING GROUP_VARS] *****************************************************

TASK [CONFIGURE SNMP COMMUNITY USING VARIABLES STORED IN GROUP_VARS] ****************************
[WARNING]: ansible-pylibssh not installed, falling back to paramiko
[WARNING]: ansible-pylibssh not installed, falling back to paramiko
[WARNING]: To ensure idempotency and correct diff the input configuration lines should be
similar to how they appear if present in the running configuration on device
changed: [dist-rtr02] => {"banners": {}, "changed": true, "commands": ["snmp-server community cisco_test RO", "snmp-server location Lisbon_HQ floor_1", "snmp-server contact Jesus_Illescas"], "updates": ["snmp-server community cisco_test RO", "snmp-server location Lisbon_HQ floor_1", "snmp-server contact Jesus_Illescas"]}
changed: [dist-rtr01] => {"banners": {}, "changed": true, "commands": ["snmp-server community cisco_test RO", "snmp-server location Lisbon_HQ floor_2", "snmp-server contact Jesus_Illescas"], "updates": ["snmp-server community cisco_test RO", "snmp-server location Lisbon_HQ floor_2", "snmp-server contact Jesus_Illescas"]}

PLAY RECAP **************************************************************************************
dist-rtr01                 : ok=1    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
dist-rtr02                 : ok=1    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

/home #

If we look for snmp in our devices, we can see the changes were done

dist-rtr01
dist-rtr01#show run | i snmp
snmp-server community cisco_test RO
snmp-server location Lisbon_HQ floor_1
snmp-server contact Jesus_Illescas
dist-rtr01#
dist-rtr02
dist-rtr02#show run | i snmp
snmp-server community cisco_test RO
snmp-server location Lisbon_HQ floor_2
snmp-server contact Jesus_Illescas
dist-rtr02#

This may be a simple change, but the purpose of this verification is to test that our image works rather than to do a complex change.

What's next?

Well, it was a long post, but actually this is just one of the foundations for the ideas I have in mind.

There three more thoughts I have for Ansible.

  • First do a more sophisticated change, snmp is just too simple.
  • Add Ansible to a pipeline, so we can see its potential in CI/CD.
  • Play with tests in Ansible.

After this, I'm thinking to integrate other tools to the network automation project I'm building.