CI/CD with Ansible
Objective
In this lesson we integrate Ansible into a CI/CD pipeline using Git-based change management and automated testing. You will create a Git workflow for network changes, author an Ansible playbook that updates device configuration, and wire that process into a CI server so every change is validated (syntax and dry-run) before it is applied to routers. This matters in production because automated testing and controlled deployment reduce human error and provide audit trails for network changes — essential for large-scale enterprise networks.
Topology
Below is the topology referenced earlier in the course (Lesson 1). This lesson adds a Git server and a CI server to enable the CI/CD pipeline.
ASCII topology (management network 10.0.0.0/24):
[Ansible Control]
ansible.lab.nhprep.com
10.0.0.10/24
|
| VLAN 10 mgmt
|
[SW1] management VLAN 10
|
-----------------------------
| |
[Core1] [Edge1] core1.lab.nhprep.com edge1.lab.nhprep.com Gi0/0 10.0.0.1/24 Gi0/0 10.0.0.2/24
Additional infrastructure:
- Git server: git.lab.nhprep.com — 10.0.0.20/24 (new)
- CI server: ci.lab.nhprep.com — 10.0.0.30/24 (new)
Device Table
| Device | Hostname (FQDN) | Management IP |
|---|---|---|
| Ansible Control | ansible.lab.nhprep.com | 10.0.0.10 |
| Git server | git.lab.nhprep.com | 10.0.0.20 |
| CI server | ci.lab.nhprep.com | 10.0.0.30 |
| Core router | core1.lab.nhprep.com (R1) | 10.0.0.1 |
| Edge router | edge1.lab.nhprep.com (R2) | 10.0.0.2 |
Quick Recap
We already have two routers (Core1 and Edge1) reachable on the management network 10.0.0.0/24. For CI/CD we have added:
- Git server at 10.0.0.20
- CI server at 10.0.0.30
- Ansible control already at 10.0.0.10
All management connections use SSH. The workflow will be: developers push Ansible code to Git → CI server triggers tests → if tests pass, CI invokes Ansible on ansible.lab.nhprep.com to apply the change to Core1 and Edge1.
Key Concepts
- Git-based change management (GitOps): Your network intent is declared as code (Ansible playbooks). Every change is versioned. In production, this provides auditability and rollback capability.
- Idempotency and Ansible modules: Network modules (e.g., ios_config) are designed to be idempotent — applying the same playbook twice leaves devices in the same state. This reduces configuration drift.
- Syntax and dry-run testing:
ansible-playbook --syntax-checkvalidates YAML and module usage without connecting to devices.ansible-playbook --checkattempts a dry-run; note that not all network modules fully support check mode — always validate results. - SSH and network_cli: Ansible uses SSH to connect to IOS devices. When using
connection: network_cli, Ansible starts an SSH session, enters enable mode as needed, then sendsconfigure terminaland configuration commands. - CI pipeline stages: Typical stages are linting (ansible-lint), syntax check, dry-run, test on lab devices, and deploy. In production, gate promotion through approvals at certain stages is common.
Steps
Step 1: Initialize central Git repository on git.lab.nhprep.com
What we are doing: Create a central bare Git repository on the Git server to store Ansible playbooks and host CI hooks. This central repo is the single source of truth for network changes and enables CI triggers on push/merge.
! No router commands in this step; Git server and control host commands shown below
On the Git server (10.0.0.20):
sudo mkdir -p /srv/git/ansible-network.git
cd /srv/git/ansible-network.git
sudo git init --bare
sudo chown -R git:git /srv/git/ansible-network.git
On the Ansible control host (10.0.0.10) initialize and push:
mkdir -p ~/ansible-network
cd ~/ansible-network
git init
cat > inventory <<EOF
[routers]
core1 ansible_host=10.0.0.1 ansible_user=ansible ansible_password=Lab@123 ansible_network_os=ios ansible_connection=network_cli
edge1 ansible_host=10.0.0.2 ansible_user=ansible ansible_password=Lab@123 ansible_network_os=ios ansible_connection=network_cli
EOF
git add inventory
git commit -m "Initial inventory"
git remote add origin ssh://git@10.0.0.20:/srv/git/ansible-network.git
git push -u origin master
What just happened: The Git server contains a bare repository ready to accept pushes. The Ansible control host committed an initial inventory and pushed it to the central repo. This allows CI to clone the repository and run tests against the same version of code developers push.
Real-world note: In production, repositories are typically hosted with access controls and pull-request workflows; a bare repo like this is acceptable for lab or internal use.
Verify:
ssh git@10.0.0.20 "ls -la /srv/git/ansible-network.git"
Expected output:
total 28
drwxr-xr-x 7 git git 4096 Apr 2 12:00 .
drwxr-xr-x 3 root root 4096 Apr 2 11:59 ..
-rw-r--r-- 1 git git 23 Apr 2 12:00 HEAD
drwxr-xr-x 2 git git 4096 Apr 2 12:00 branches
-rw-r--r-- 1 git git 113 Apr 2 12:00 config
drwxr-xr-x 3 git git 4096 Apr 2 12:00 hooks
drwxr-xr-x 2 git git 4096 Apr 2 12:00 info
drwxr-xr-x 2 git git 4096 Apr 2 12:00 objects
drwxr-xr-x 2 git git 4096 Apr 2 12:00 refs
Step 2: Configure device accounts for Ansible on Core1 and Edge1
What we are doing: Create a local user, set the domain, and enable SSH so Ansible can authenticate to the routers. Without SSH and appropriate privilege, Ansible cannot push configuration.
! On Core1 (10.0.0.1)
configure terminal
ip domain-name lab.nhprep.com
username ansible privilege 15 secret Lab@123
crypto key generate rsa modulus 2048
line vty 0 4
login local
transport input ssh
end
write memory
! On Edge1 (10.0.0.2)
configure terminal
ip domain-name lab.nhprep.com
username ansible privilege 15 secret Lab@123
crypto key generate rsa modulus 2048
line vty 0 4
login local
transport input ssh
end
write memory
What just happened: Each router now has a local user ansible with secret Lab@123. ip domain-name is required for RSA key generation and for SSH to present a proper host key. line vty changes allow SSH logins using local authentication. These steps let Ansible open an SSH session and enter enable/configuration mode as needed.
Real-world note: In production you should use AAA with TACACS+/RADIUS and certificate-based device authentication; local users are acceptable only in small environments or labs.
Verify:
! On Core1
show running-config | section username
Expected output:
username ansible privilege 15 secret 5 $1$abcd$abcdefghijk1234567890
! On Core1
show ip ssh
Expected output:
SSH Enabled - version 2.0
Authentication timeout: 120 secs; Authentication retries: 3
(Repeat the same verification on Edge1, showing the same user and SSH enabled output.)
Step 3: Write an Ansible playbook that configures interface descriptions
What we are doing: Create a reusable playbook using the ios_config module to set interface descriptions. This is the change we will manage via Git and test via CI. Using modules keeps operations idempotent.
Playbook file: set-interface-desc.yml
- name: Set interface descriptions on routers
hosts: routers
gather_facts: no
connection: network_cli
tasks:
- name: Configure loopback description (example)
ios_config:
lines:
- description Configured by Ansible via CI
parents:
- interface Loopback0
Inventory (already committed in Step 1) references the ansible_user and ansible_password.
Run a syntax check locally before pushing:
ansible-playbook --syntax-check -i inventory set-interface-desc.yml
What just happened: The playbook is validated for YAML syntax and module usage without connecting to devices. connection: network_cli tells Ansible to use an SSH session suitable for network devices. The ios_config module will translate the lines into configure terminal commands when executed.
Real-world note: Use consistent parent/child configuration blocks to limit risk (e.g., change only an interface section), reducing blast radius.
Verify (syntax):
ansible-playbook --syntax-check -i inventory set-interface-desc.yml
Expected output:
playbook: set-interface-desc.yml
Now run a dry-run:
ansible-playbook -i inventory set-interface-desc.yml --check --diff
Expected output (example):
PLAY [Set interface descriptions on routers] *********************************
TASK [Configure loopback description (example)] *******************************
changed: [core1]
changed: [edge1]
PLAY RECAP *******************************************************************
core1 : ok=1 changed=1 unreachable=0 failed=0
edge1 : ok=1 changed=1 unreachable=0 failed=0
Note: Some network modules may not implement full check-mode; output may show changed even in --check. Understand the module behavior for production.
Step 4: Commit changes and push to Git; configure CI to run tests
What we are doing: Demonstrate the Git branch and push workflow and provide a CI pipeline definition (Jenkinsfile) that performs linting, syntax checks, dry-run, and deployment. This enables automated gating of changes.
On Ansible control:
git checkout -b feature/interface-desc
git add set-interface-desc.yml
git commit -m "Add interface description playbook"
git push -u origin feature/interface-desc
Example Jenkinsfile (ci.lab.nhprep.com will clone repo and run):
pipeline {
agent any
stages {
stage('Checkout') {
steps {
git url: 'ssh://git@10.0.0.20:/srv/git/ansible-network.git', branch: 'feature/interface-desc'
}
}
stage('Lint') {
steps {
sh 'ansible-lint set-interface-desc.yml || true'
}
}
stage('Syntax Check') {
steps {
sh 'ansible-playbook --syntax-check -i inventory set-interface-desc.yml'
}
}
stage('Dry-run') {
steps {
sh 'ansible-playbook -i inventory set-interface-desc.yml --check --diff'
}
}
stage('Deploy') {
steps {
input message: 'Approve deployment to devices?'
sh 'ansible-playbook -i inventory set-interface-desc.yml'
}
}
}
}
What just happened: The Git push creates a branch holding the change. The CI pipeline clones the branch, runs linting and syntax checks to catch mistakes early, then runs a dry-run test. If everything passes and manual approval is given, the pipeline runs the playbook to apply the change to devices.
Real-world note: Production pipelines often include automated tests against a lab topology first, then require human approval before production deployment.
Verify (CI run simulated via shell):
# Simulate CI executing the deploy stage
ansible-playbook -i inventory set-interface-desc.yml
Expected output (full playbook run):
PLAY [Set interface descriptions on routers] *******************************
TASK [Configure loopback description (example)] *****************************
changed: [core1]
changed: [edge1]
PLAY RECAP *****************************************************************
core1 : ok=1 changed=1 unreachable=0 failed=0
edge1 : ok=1 changed=1 unreachable=0 failed=0
Step 5: Verify the configuration on the routers
What we are doing: Confirm that the intended configuration was applied on Core1 and Edge1. This is the final verification step — always inspect device state after automation makes changes.
! On Core1
show running-config interface Loopback0
Expected output:
Building configuration...
Current configuration : 57 bytes
!
interface Loopback0
description Configured by Ansible via CI
!
end
! On Edge1
show running-config interface Loopback0
Expected output:
Building configuration...
Current configuration : 57 bytes
!
interface Loopback0
description Configured by Ansible via CI
!
end
What just happened: The routers show the Loopback0 interface description set to the string added by the playbook. This validates the end-to-end workflow from Git push → CI checks → Ansible deployment → device configuration.
Real-world note: Always include verification steps in the pipeline (e.g., run
showvia Ansible and assert expected output) to automatically confirm success.
Verification Checklist
- Check 1: The Git repository exists on git.lab.nhprep.com — verify with
ssh git@10.0.0.20 "ls -la /srv/git/ansible-network.git" - Check 2: Both routers accept SSH logins for user
ansible— verify withssh ansible@10.0.0.1(oransible -m pingagainstcore1) - Check 3: Playbook passes syntax check —
ansible-playbook --syntax-check -i inventory set-interface-desc.yml - Check 4: Dry-run executes without error —
ansible-playbook -i inventory set-interface-desc.yml --check --diff - Check 5: Configuration appears on devices —
show running-config interface Loopback0on both routers and verify description
Common Mistakes
| Symptom | Cause | Fix |
|---|---|---|
| CI job fails with SSH authentication error | Wrong username/password or SSH keys not configured | Ensure inventory has correct ansible_user/ansible_password or configure SSH keys; test ssh ansible@10.0.0.1 manually |
ansible-playbook --syntax-check passes but playbook fails on devices | Network modules may require enabling enable mode or different connection settings | Ensure inventory has correct ansible_connection=network_cli and ansible_become=yes if needed; set ansible_password and ansible_become_password |
Dry-run (--check) shows changes but actual run makes unexpected changes | Some modules do not fully implement check mode | Read module docs; include pre-deployment interface/state checks and post-deployment verification |
| YAML parsing error in CI logs | Incorrect indentation or tabs in playbook | Run ansible-playbook --syntax-check locally and fix indentation; use spaces only |
Key Takeaways
- Use Git as the single source of truth for Ansible playbooks; branches and PRs allow controlled review of network changes.
- Always perform syntax checks and dry-run tests before applying changes; CI should automate these stages to catch errors early.
- Ansible's network_cli opens SSH sessions to devices and issues
configure terminalcommands; ensure devices permit SSH and appropriate credentials are available. - Build verification into the pipeline: after deployment, automatically query devices to confirm expected state — this turns a pipeline from deploy-only into a true CI/CD loop for networks.
Tip: In production, integrate change approval and auditing (e.g., signed commits, role-based access) and test changes in a dedicated lab fabric before applying to production routers.