Network Config Generator in Python
Build a CLI tool that generates Cisco IOS configs and checks compliance.
Introduction
30 Second Summary
Imagine you manage a datacenter with hundreds of network devices. Every switch and router needs a configuration file, and every file must follow your team's security standards. Doing this by hand is slow, error-prone, and impossible to scale.
In this project, you will build a Python CLI tool that generates standardized Cisco IOS configurations for an entire device inventory from a single command, then validates those configs against your defined network standards.
What You'll Build
You'll run one command and watch it produce unique, valid network configurations for every device in a multi-datacenter inventory, then run your compliance checker and get a report showing which devices meet your standards and which ones have drifted.
By the end of this project, you'll have:
- A data-driven inventory that models switches and routers across multiple datacenters. Change your YAML file from 5 devices to 500 and the tool scales without a single code change.
- A Jinja2 template engine that produces role-specific Cisco IOS configs. Spine switches get trunk interfaces and LACP, leaf switches get access ports and voice VLANs, and edge routers get NAT and access-lists.
- A compliance checker that reads any config file, compares it against your network standards, and flags violations with specific detail. You'll see exactly which device failed and why.
- Secret Mission: Add a drift detection mode that compares a device's current config against its intended state and highlights the exact lines that differ.
Are there any prerequisites?
You need basic Python knowledge (loops, functions, file I/O, and dictionaries). No networking hardware is required. Everything runs locally on your machine using simulated configurations.
Before We Start
Before We Start...
Before we dive in, let's lock in what you're about to build and why it matters.
Set Up Your Project and Install Dependencies
Before you can generate a single network configuration, you need a clean workspace and the right tools installed. Your generator will rely on two Python packages: Jinja2 for templating and PyYAML for reading structured data files.
In this step, you will create a dedicated project directory, set up an isolated Python environment, and install both packages. By the end, a single import check will confirm everything is wired up correctly.
In this step, get ready to:
- Create the project directory structure with folders for templates, data, and output.
- Create a virtual environment and install Jinja2 and PyYAML.
- Verify both packages import correctly.
Create the project directory structure
Your project needs separate folders for templates, inventory data, and generated output files. This separation keeps the project organized as you add more devices and roles.
- Open your terminal by pressing Cmd+Space to open Spotlight, typing Terminal, and pressing Enter.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder with its subdirectories by running these commands:
mkdir network-deployment-tool
cd network-deployment-tool
mkdir templates data output
- Confirm the folders were created by running:
ls
You should see data, output, and templates listed in the output.
Don't see the three folders?
Make sure you ran cd network-deployment-tool before the second mkdir command. If you see network-deployment-tool in the ls output instead, you are still on the Desktop. Run cd network-deployment-tool and then mkdir templates data output again.
Help me figure out why my project folders didn't get created.
Create a virtual environment and install packages
A virtual environment keeps the packages for this project separate from your system Python. This prevents version conflicts with other projects on your machine.
- Create and activate a virtual environment by running these commands:
python3 -m venv venv
source venv/bin/activate
Your terminal prompt now shows (venv) at the beginning. This confirms you are working inside the isolated environment.
What is a virtual environment?
A virtual environment is a self-contained directory that holds its own Python interpreter and installed packages. Anything you install with pip while the environment is active stays inside the venv/ folder and does not affect your system-wide Python.
- Install the two packages your project needs by running:
pip install Jinja2==3.1.6 PyYAML==6.0.3
You should see download progress and a line that says Successfully installed followed by the package names and versions.
Seeing a connection error or permission denied?
If you see a network error, check that you are connected to the internet. If you see a permissions error, make sure your prompt shows (venv) confirming the virtual environment is active. Run source venv/bin/activate again if needed.
Help me troubleshoot my pip install error.
Verify both packages import correctly
Before you move on, you need proof that both packages are importable and ready to use. A single Python one-liner will test this.
Before you run the next command, what do you expect to see if both packages installed correctly?
- Verify your setup by running this command:
python -c "from jinja2 import Environment; import yaml; print('Ready')"
You should see Ready printed on a single line with no errors above it. That confirms both Jinja2 and PyYAML are installed and importable.
Seeing ModuleNotFoundError?
This means the packages were installed outside your active environment. Check that your prompt shows (venv). If it does not, run source venv/bin/activate and try the verification command again.
If the prompt shows (venv) and you still see the error, re-run pip install Jinja2==3.1.6 PyYAML==6.0.3 to reinstall.
Help me fix my ModuleNotFoundError for Jinja2 or PyYAML.
Now open PyCharm on your new project so you are ready to write code in the next step.
- Press Cmd+Space to open Spotlight, type PyCharm, and press Enter.
- In PyCharm, click Open on the Welcome screen.
- Navigate to Desktop, select the network-deployment-tool folder, and click Open.
You should see the templates, data, and output folders in PyCharm's Project panel on the left.
Your workspace is ready to go. Next up, you will build a device inventory and use your new Jinja2 template engine to generate your first Cisco IOS configuration.
Build the Device Inventory and Generate Your First Config
Your environment is ready, with Jinja2 and PyYAML installed. Now it is time to build the core engine that turns raw device data into valid network configurations.
A hardcoded config script is useless at scale. If you have 5 devices today but 500 next quarter, you need a data-driven approach. In this step, you will create a device inventory, a configuration template, and a Python script that combines the two to produce unique configs for every device automatically.
In this step, get ready to:
- Create a YAML inventory file modeling 6 devices across 2 datacenters.
- Create a Jinja2 template that defines the structure of a Cisco IOS configuration.
- Write a generation script that renders a unique config per device and saves each to a file.
Create the YAML inventory file
The inventory is the single source of truth for every device your tool manages. Each device entry includes its hostname, role, management IP, and assigned VLANs. Structuring this data in YAML means you can scale from a handful of devices to hundreds without changing your code.
- Create a new file called inventory.yaml inside your data/ folder. In PyCharm, right-click the data folder in the project panel, select New, then File, and name it inventory.yaml.
- Paste the following content into data/inventory.yaml to define the first datacenter with two spine switches:
datacenters:
- name: us-west-2
location: Oregon
devices:
- hostname: us-west-2-spine-01
role: spine
management_ip: 10.0.1.1
vlans:
- id: 100
name: management
- id: 200
name: production
- id: 300
name: storage
- hostname: us-west-2-spine-02
role: spine
management_ip: 10.0.1.2
vlans:
- id: 100
name: management
- id: 200
name: production
- id: 300
name: storage
What does this YAML structure do?
The top-level datacenters key holds a list of datacenter objects. Each datacenter contains a list of devices. Every device has a role (spine, leaf, or edge) that determines what kind of config it receives.
The vlans list is nested inside each device, so different device types can carry different VLAN assignments. This nested structure is exactly why YAML beats flat CSV for network data.
- Add the remaining two us-west-2 devices (a leaf switch and an edge router) directly below the last line you just pasted:
- hostname: us-west-2-leaf-01
role: leaf
management_ip: 10.0.2.1
vlans:
- id: 100
name: management
- id: 200
name: production
- id: 400
name: voice
- hostname: us-west-2-edge-01
role: edge
management_ip: 10.0.3.1
vlans:
- id: 100
name: management
- Verify your YAML is valid so far by running this in your terminal:
python3 -c "import yaml; data=yaml.safe_load(open('data/inventory.yaml')); print(len(data['datacenters'][0]['devices']), 'devices in us-west-2')"
You should see 4 devices in us-west-2 printed.
Seeing a YAML parsing error?
YAML is sensitive to indentation. Each nested level uses exactly 2 spaces. Make sure there are no tab characters and that each device entry lines up at the same indentation level.
help me fix a YAML indentation error in my inventory file
- Now add the second datacenter. Paste this below the last line of your file, leaving one blank line between the two datacenter entries:
- name: us-east-1
location: Virginia
devices:
- hostname: us-east-1-spine-01
role: spine
management_ip: 10.1.1.1
vlans:
- id: 100
name: management
- id: 200
name: production
- id: 300
name: storage
- hostname: us-east-1-leaf-01
role: leaf
management_ip: 10.1.2.1
vlans:
- id: 100
name: management
- id: 200
name: production
- id: 400
name: voice
- Save data/inventory.yaml.
- Confirm all 6 devices load correctly by running:
python3 -c "import yaml; data=yaml.safe_load(open('data/inventory.yaml')); total=sum(len(dc['devices']) for dc in data['datacenters']); print(total, 'devices across', len(data['datacenters']), 'datacenters')"
You should see 6 devices across 2 datacenters printed.
Create the Jinja2 configuration template
A Jinja2 template defines the skeleton of your Cisco IOS configuration. Placeholders like {{ hostname }} get replaced with real values from each device in the inventory. Loops and conditionals let one template produce different output per device role.
- Create a new file called base.j2 inside your templates/ folder. In PyCharm, right-click the templates folder, select New then File, and name it base.j2.
- Paste the first section of the template, which covers the device header, NTP, SNMP, and SSH hardening:
!
! Generated configuration for {{ hostname }}
! Datacenter: {{ datacenter }} ({{ location }})
! Role: {{ role }}
! Generated by Network Deployment Tool
!
hostname {{ hostname }}
!
ip domain-name aws.internal
!
ntp server 10.100.1.1
ntp server 10.100.1.2
!
snmp-server community n3tw0rk-r0
snmp-server location {{ datacenter }} {{ location }}
snmp-server contact noc@aws.internal
!
ip ssh version 2
ip ssh time-out 60
ip ssh authentication-retries 3
!
banner motd ^
*** AUTHORIZED ACCESS ONLY ***
This device is managed by the Network Deployment Tool.
Unauthorized changes will be detected and reverted.
^
!
{% for vlan in vlans %}
vlan {{ vlan.id }}
name {{ vlan.name }}
!
{% endfor %}
What does this template section do?
Everything inside {{ }} is a variable placeholder. When the script renders this template, it substitutes real device values from the inventory.
The {% for vlan in vlans %} block loops through each VLAN assigned to the device, generating a separate vlan block for each one. A device with 3 VLANs produces 3 VLAN config sections automatically.
- Continue the template by pasting the loopback interface, VTY lines, and the spine switch role block:
!
interface Loopback0
description Management Interface
ip address {{ management_ip }} 255.255.255.255
!
line vty 0 4
transport input ssh
login local
!
{% if role == "spine" %}
! --- Spine Switch Configuration ---
{% for i in range(1, 5) %}
interface GigabitEthernet0/{{ i }}
description downlink-to-leaf-0{{ i }}
switchport mode trunk
switchport trunk allowed vlan 100,200,300
channel-group {{ i }} mode active
no shutdown
!
{% endfor %}
- Confirm the template is taking shape by running:
wc -l templates/base.j2
You should see approximately 50 lines.
- Add the leaf switch role block:
{% elif role == "leaf" %}
! --- Leaf Switch Configuration ---
{% for i in range(1, 13) %}
interface GigabitEthernet0/{{ i }}
description access-port-{{ i }}
switchport mode access
switchport access vlan 200
switchport voice vlan 400
spanning-tree portfast
no shutdown
!
{% endfor %}
interface GigabitEthernet0/48
description uplink-to-spine
switchport mode trunk
switchport trunk allowed vlan 100,200,400
no shutdown
!
Why different role blocks?
A spine switch connects to other switches via trunk ports. A leaf switch connects to end devices via access ports. The {% if role == "spine" %} conditional ensures each device type gets only the interfaces and settings relevant to its function.
- Add the edge router role block and close the template:
{% elif role == "edge" %}
! --- Edge Router Configuration ---
ip route 0.0.0.0 0.0.0.0 10.0.0.1
!
ip access-list extended OUTSIDE-IN
permit tcp any any eq 443
permit tcp any any eq 80
permit icmp any any echo-reply
deny ip any any log
!
interface GigabitEthernet0/0
description uplink-to-isp
ip address dhcp
ip access-group OUTSIDE-IN in
ip nat outside
no shutdown
!
interface GigabitEthernet0/1
description internal-network
ip address 10.0.0.254 255.255.255.0
ip nat inside
no shutdown
!
ip nat inside source list 1 interface GigabitEthernet0/0 overload
access-list 1 permit 10.0.0.0 0.0.255.255
!
{% endif %}
end
- Save templates/base.j2.
- Confirm the final line count:
wc -l templates/base.j2
You should see approximately 82 lines. Your template covers all three device roles.
What is a Jinja2 .j2 file?
The .j2 extension is a convention indicating a Jinja2 template file. The file itself is plain text mixed with Jinja2 syntax ({{ }} for variables, {% %} for logic). Your Python script will load and render it.
Write the generation script
The generation script ties everything together. It loads the inventory, feeds each device's data into the template, and writes the rendered output to individual .cfg files in the output/ directory.
- Create a new file called generate.py in your project root (the same level as your data/ and templates/ folders). In PyCharm, right-click the project root, select New then Python File, and name it generate.
- Paste the imports and the inventory loading function:
import os
import yaml
from jinja2 import Environment, FileSystemLoader
def load_inventory(inventory_path):
# Open the YAML inventory and parse it into a Python dictionary
with open(inventory_path, "r") as f:
return yaml.safe_load(f)
What does this code do?
The yaml.safe_load(f) function reads the YAML file and converts it into a nested Python dictionary. The safe_load variant prevents execution of arbitrary code that could be embedded in a YAML file.
The from jinja2 import Environment, FileSystemLoader imports give you the template engine. FileSystemLoader tells Jinja2 where to find your template files on disk.
- Add the main generation function below load_inventory:
def generate_configs(inventory_data, output_dir, datacenter_filter=None):
# Set up Jinja2 to load templates from the templates/ directory
env = Environment(
loader=FileSystemLoader("templates"),
trim_blocks=True,
lstrip_blocks=True,
)
template = env.get_template("base.j2")
os.makedirs(output_dir, exist_ok=True)
generated = []
for dc in inventory_data["datacenters"]:
if datacenter_filter and dc["name"] != datacenter_filter:
continue
for device in dc["devices"]:
# Build context dictionary with device details for the template
context = {
"hostname": device["hostname"],
"role": device["role"],
"management_ip": device["management_ip"],
"vlans": device["vlans"],
"datacenter": dc["name"],
"location": dc["location"],
}
# Render the template and write the config to a file
config = template.render(context)
filename = f"{device['hostname']}.cfg"
filepath = os.path.join(output_dir, filename)
with open(filepath, "w") as f:
f.write(config)
generated.append(filename)
return generated
What does this function do?
The Environment object configures Jinja2. The trim_blocks=True and lstrip_blocks=True settings remove unwanted blank lines that template tags would otherwise leave in the rendered output.
The function iterates over every device in the inventory, builds a context dictionary with that device's data, renders the template, and writes the result to a .cfg file named after the device hostname. The optional datacenter_filter parameter lets you generate configs for just one datacenter.
- Add the main entry point at the bottom of the file:
def main():
inventory = load_inventory("data/inventory.yaml")
generated = generate_configs(inventory, "output")
print(f"Generated {len(generated)} configuration files:")
for filename in generated:
print(f" output/{filename}")
if __name__ == "__main__":
main()
- Save generate.py.
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files: data/inventory.yaml, templates/base.j2, and generate.py.
ⓧ I'd like to double check the full code
Scroll up and confirm these sections are present in each file, in order:
- data/inventory.yaml: us-west-2 datacenter (spine-01, spine-02, leaf-01, edge-01) followed by us-east-1 datacenter (spine-01, leaf-01). Total: 6 devices.
- templates/base.j2: Header and hostname section, NTP/SNMP/SSH section, banner, VLAN loop, Loopback0 interface, VTY lines, then three role blocks (spine, leaf, edge) ending with end.
- generate.py: Imports, load_inventory() function, generate_configs() function, then main() with the if __name__ guard.
Before you run this, what do you think the output will show? How many files will it generate, and what will they be named?
- Run the generation script:
python3 generate.py
You should see this output:
Generated 6 configuration files:
output/us-west-2-spine-01.cfg
output/us-west-2-spine-02.cfg
output/us-west-2-leaf-01.cfg
output/us-west-2-edge-01.cfg
output/us-east-1-spine-01.cfg
output/us-east-1-leaf-01.cfg
- Verify a generated config file looks correct by running:
head -15 output/us-west-2-spine-01.cfg
You should see the first 15 lines of a valid Cisco IOS config with the correct hostname, NTP servers, and SNMP settings.
Seeing 0 configuration files or an error?
If you get a FileNotFoundError for the inventory, make sure you are running the command from your project root directory (the folder containing data/, templates/, and generate.py).
If you get a TemplateNotFound error, confirm your template is named exactly base.j2 and sits inside the templates/ folder.
help me debug why my generate.py script is failing
You just turned a single command into 6 unique, valid network configurations. Next up, you will make those configs smarter by ensuring each device role gets the specific interfaces and settings it needs.
Add Role-Based Templates for Different Device Types
Your generator is running and producing config files for every device in the inventory. Now it's time to understand how the template differentiates between device roles.
In a real datacenter, a spine switch, a leaf switch, and an edge router serve completely different purposes. A spine switch moves traffic between racks using trunked uplinks. A leaf switch connects end-user devices on access ports. An edge router handles traffic entering and leaving the network. Each role demands its own configuration sections.
In this step, get ready to:
- Examine the role-specific template blocks in base.j2 for spine, leaf, and edge devices.
- Understand how generate.py uses Jinja2 conditional blocks to produce different configs per role.
- Verify that spine, leaf, and edge devices each generate distinct configuration output.
Examine the spine switch template block
A spine switch sits at the top of a datacenter network fabric. Its job is to aggregate traffic from leaf switches below it using high-speed trunk links bundled into LACP channel-groups.
- Open templates/base.j2 in PyCharm.
- Scroll down past the VLANs loop and VTY line configuration until you find this section:
{% if role == "spine" %}
! --- Spine Switch Configuration ---
{% for i in range(1, 5) %}
interface GigabitEthernet0/{{ i }}
description downlink-to-leaf-0{{ i }}
switchport mode trunk
switchport trunk allowed vlan 100,200,300
channel-group {{ i }} mode active
no shutdown
!
{% endfor %}
What does this block do?
The {% if role == "spine" %} conditional checks the device's role from the inventory data. If it matches "spine", Jinja2 renders this entire block.
- The for i in range(1, 5) loop creates 4 trunk interfaces (GigabitEthernet0/1 through 0/4), each connecting down to a leaf switch.
- switchport mode trunk allows the port to carry multiple VLANs simultaneously.
- channel-group {{ i }} mode active bundles the link into a LACP port-channel for redundancy and increased bandwidth.
Examine the leaf switch template block
A leaf switch connects directly to end-user devices like servers and IP phones. It uses access ports (one VLAN per port) instead of trunk ports.
- Find the {% elif role == "leaf" %} section directly below the spine block:
{% elif role == "leaf" %}
! --- Leaf Switch Configuration ---
{% for i in range(1, 13) %}
interface GigabitEthernet0/{{ i }}
description access-port-{{ i }}
switchport mode access
switchport access vlan 200
switchport voice vlan 400
spanning-tree portfast
no shutdown
!
{% endfor %}
interface GigabitEthernet0/48
description uplink-to-spine
switchport mode trunk
switchport trunk allowed vlan 100,200,400
no shutdown
!
What does this block do?
- The loop creates 12 access ports (GigabitEthernet0/1 through 0/12) for connecting servers and workstations.
- switchport voice vlan 400 adds a dedicated voice VLAN for IP phones that share the same port as a computer.
- spanning-tree portfast lets the port forward traffic immediately instead of waiting 30 seconds for spanning-tree convergence.
- GigabitEthernet0/48 is the uplink port connecting back to the spine switch, configured as a trunk so it can carry all VLANs upstream.
Examine the edge router template block
An edge router sits at the boundary between your internal network and the outside world. It handles routing, firewall rules, and NAT.
- Find the {% elif role == "edge" %} section below the leaf block:
{% elif role == "edge" %}
! --- Edge Router Configuration ---
ip route 0.0.0.0 0.0.0.0 10.0.0.1
!
ip access-list extended OUTSIDE-IN
permit tcp any any eq 443
permit tcp any any eq 80
permit icmp any any echo-reply
deny ip any any log
!
interface GigabitEthernet0/0
description uplink-to-isp
ip address dhcp
ip access-group OUTSIDE-IN in
ip nat outside
no shutdown
!
interface GigabitEthernet0/1
description internal-network
ip address 10.0.0.254 255.255.255.0
ip nat inside
no shutdown
!
ip nat inside source list 1 interface GigabitEthernet0/0 overload
access-list 1 permit 10.0.0.0 0.0.255.255
!
What does the edge block do?
- ip route 0.0.0.0 0.0.0.0 10.0.0.1 sets a default route. Any traffic without a more specific destination goes to the ISP gateway.
- The OUTSIDE-IN access-list acts as a firewall. It permits HTTPS (443), HTTP (80), and ICMP replies, then denies everything else.
- The NAT configuration lets internal devices (10.0.0.0/16) share the single public IP address on GigabitEthernet0/0.
How does generate.py select the right template block?
You do not need a separate template file per role. The Jinja2 conditionals ({% if role == "spine" %}, {% elif role == "leaf" %}, {% elif role == "edge" %}) inside the single template handle branching automatically.
When generate.py passes "role": device["role"] into the context dictionary, Jinja2 evaluates the conditional and renders only the matching block. The same template produces entirely different output depending on the inventory data.
Regenerate all configs and compare role output
Time to see the role differentiation in action. You have 3 spine switches, 2 leaf switches, and 1 edge router across your two datacenters.
- Regenerate all configuration files by running this command:
python generate.py
You should see output confirming 6 configuration files were generated.
Before you compare the files, predict: what specific configuration lines will a spine switch have that an edge router will not?
- View the spine switch config by running:
cat output/us-west-2-spine-01.cfg
You should see the base configuration (hostname, NTP, SNMP, SSH, banner, VLANs) followed by the spine-specific section with 4 trunk interfaces and LACP channel-groups.
- Now view the edge router config by running:
cat output/us-west-2-edge-01.cfg
The edge router has an access-list, a default route, and NAT configuration instead of trunk interfaces. Different roles produce entirely different configs from the same template.
- Verify the leaf switch has its own distinct output by running:
cat output/us-west-2-leaf-01.cfg
The leaf config shows 12 access ports with voice VLANs and a single trunk uplink on port 48. Three roles, three completely different outputs, one template.
All configs look identical?
- Check that your data/inventory.yaml has different role values (spine, leaf, edge) for different devices.
- Verify the {% if role == "spine" %} conditionals in templates/base.j2 use == (double equals), not = (single equals).
- Make sure you have trim_blocks=True and lstrip_blocks=True in your Environment() call in generate.py. Without these, blank lines accumulate and can obscure the role-specific sections.
Help me debug why my Jinja2 template isn't producing different output per device role.
✔️ Awesome, I've got everything!
Great. Confirm you have saved templates/base.j2 and that running python generate.py produces 6 config files with distinct role sections.
ⓧ I'd like to double check the full code
Here is the complete templates/base.j2 template. Scroll up and confirm you have each section in order: the header comment, hostname, NTP, SNMP, SSH, banner, VLANs loop, Loopback0, VTY lines, then the role-specific conditional blocks (spine, leaf, edge), and finally end.
The template is longer than 30 lines so it is split across the chunks above. Confirm these sections are present in your file, in this order:
- Header comment block with {{ hostname }}, {{ datacenter }}, and {{ role }}.
- Base config: hostname, domain-name, NTP, SNMP, SSH, banner.
- VLANs loop ({% for vlan in vlans %}).
- Loopback0 interface and VTY lines.
- Spine block: 4 trunk interfaces with LACP channel-groups.
- Leaf block: 12 access ports, voice VLAN, uplink on port 48.
- Edge block: default route, OUTSIDE-IN ACL, NAT, two interfaces.
- end keyword after {% endif %}.
Your template now produces three distinct configuration profiles from a single file. A spine gets trunks and LACP. A leaf gets access ports and voice VLANs. An edge gets routing, firewall rules, and NAT. Next up, you will build a compliance checker that validates these configs against your network standards.
Build the Compliance Checker
Your config generator produces standardized configurations for every device in your inventory. But generating configs is only half the job.
In production, devices accumulate manual changes over time. Someone adds a rogue VLAN. Someone disables SSH hardening for "quick troubleshooting" and forgets to revert it. Your compliance checker ensures every config still meets your defined network standards, catching violations before they cause outages.
In this step, get ready to:
- Define a standards file specifying what every device config must (and must not) contain.
- Write a compliance checker that parses config files and reports pass/fail per rule.
- Add summary statistics that highlight the most common violations across your fleet.
Define the compliance standards
Before you can check configs for compliance, you need to define what "compliant" means. Your standards file declares the rules every device must satisfy.
- Create a new file called standards.yaml inside your data/ folder by running:
touch data/standards.yaml
- Open data/standards.yaml in PyCharm and paste the following:
standards:
ntp_servers:
description: "NTP servers must be configured"
required_lines:
- "ntp server 10.100.1.1"
- "ntp server 10.100.1.2"
ssh_version:
description: "SSH version 2 must be enabled"
required_lines:
- "ip ssh version 2"
snmp_security:
description: "SNMP community must not be 'public'"
forbidden_lines:
- "snmp-server community public"
banner:
description: "Login banner must be present"
required_lines:
- "banner motd"
vty_transport:
description: "VTY lines must use SSH only"
required_lines:
- "transport input ssh"
What does this file define?
Each rule under standards has a human-readable description plus either required_lines (must exist in the config) or forbidden_lines (must NOT exist).
- ntp_servers ensures time sync is configured (critical for logging correlation).
- ssh_version enforces SSH v2 (v1 has known vulnerabilities).
- snmp_security blocks the notoriously insecure default community string "public".
- banner requires a login warning (a legal requirement in many organizations).
- vty_transport restricts remote access to SSH only (no unencrypted Telnet).
- Save data/standards.yaml.
- Confirm the file exists by running:
cat data/standards.yaml
You should see all five rules printed in your terminal.
File is empty or not found?
- Make sure you created the file inside the data/ folder, not the project root.
- Check that you saved the file after pasting.
- help me find my standards.yaml file
Write the compliance checking functions
Now you need a Python script that reads each config file, checks it against every rule in your standards, and reports the results.
- Create a new file called check_compliance.py in your project root by running:
touch check_compliance.py
- Open check_compliance.py in PyCharm and paste the following imports and standards loader:
import os
import sys
import yaml
def load_standards(standards_path):
# Read the YAML standards file into a Python dictionary
with open(standards_path, "r") as f:
return yaml.safe_load(f)
What does this code do?
- os handles file paths and directory listing.
- sys provides access to command-line arguments.
- load_standards() reads your standards.yaml into a dictionary your checker can loop through.
- Below load_standards, paste the core compliance-checking function:
def check_device_compliance(config_content, standards):
# Check one device's config against all defined standards
results = []
for rule_name, rule in standards["standards"].items():
passed = True
violation = None
if "required_lines" in rule:
for required in rule["required_lines"]:
if required not in config_content:
passed = False
violation = f"Missing: {required}"
break
if "forbidden_lines" in rule:
for forbidden in rule["forbidden_lines"]:
if forbidden in config_content:
passed = False
violation = f"Found forbidden: {forbidden}"
break
results.append({
"rule": rule_name,
"description": rule["description"],
"passed": passed,
"violation": violation,
})
return results
What does this function do?
For each rule in your standards, this function searches the device's config text for the required (or forbidden) strings. If a required line is missing or a forbidden line is found, it records the violation.
The break stops checking after the first failure per rule. One violation is enough to fail a rule.
- Verify your two functions load correctly by running:
python -c "from check_compliance import check_device_compliance; print('Functions loaded')"
You should see Functions loaded printed with no errors.
Seeing an ImportError?
- Make sure your virtual environment is active (you should see (venv) in your terminal prompt).
- Check that check_compliance.py is in your project root, not inside a subfolder.
- help me fix my check_compliance.py import error
- Back in check_compliance.py, paste the directory-scanning function below check_device_compliance:
def check_directory(config_dir, standards_path="data/standards.yaml"):
# Load standards and check every .cfg file in the directory
standards = load_standards(standards_path)
all_results = {}
for filename in sorted(os.listdir(config_dir)):
if not filename.endswith(".cfg"):
continue
filepath = os.path.join(config_dir, filename)
with open(filepath, "r") as f:
content = f.read()
results = check_device_compliance(content, standards)
all_results[filename] = results
return all_results
What does check_directory do?
This function ties everything together. It scans a directory for .cfg files, reads each one, and runs the compliance check against your standards. The result is a dictionary mapping each filename to its list of rule results.
- Next, paste the report-printing function below check_directory:
def print_report(all_results):
total_devices = len(all_results)
compliant_count = 0
print("\n" + "=" * 60)
print("NETWORK COMPLIANCE REPORT")
print("=" * 60)
# Track violations for summary statistics
violation_counts = {}
for device, results in all_results.items():
device_passed = all(r["passed"] for r in results)
if device_passed:
compliant_count += 1
status = "PASS"
else:
status = "FAIL"
print(f"\n {device}: [{status}]")
for r in results:
if not r["passed"]:
print(f" - {r['description']}: {r['violation']}")
rule = r["rule"]
violation_counts[rule] = violation_counts.get(rule, 0) + 1
print("=" * 60 + "\n")
What does print_report do?
This function loops through every device's results and prints a [PASS] or [FAIL] status. For failing devices, it lists the specific violations underneath. It also tracks violation_counts and compliant_count in preparation for summary statistics you will add next.
- Finally, paste the main function and entry point below print_report:
def main():
# Accept directory path from command line, default to output/
config_dir = sys.argv[1] if len(sys.argv) > 1 else "output"
if not os.path.isdir(config_dir):
print(f"Error: Directory '{config_dir}' not found.")
sys.exit(1)
all_results = check_directory(config_dir)
print_report(all_results)
if __name__ == "__main__":
main()
What does main do?
The main() function reads a directory path from the command line (defaulting to output/). It validates the directory exists, then runs the compliance check and prints the report.
- Save check_compliance.py.
- Run your compliance checker against the configs you generated earlier:
python check_compliance.py output/
You should see every device listed with [PASS] status. Your generated configs already meet all five standards because your template was built correctly.
Seeing a NameError or FileNotFoundError?
- If you see NameError: name 'check_directory' is not defined, make sure you pasted all functions in order (load_standards, check_device_compliance, check_directory, print_report, main).
- If you see a FileNotFoundError for data/standards.yaml, make sure your terminal is in the project root folder (where data/ lives).
- help me debug my compliance checker errors
Add summary statistics to the report
Your report shows per-device results, but when you have dozens of devices you need a quick overview. The variables compliant_count and violation_counts are already being tracked. Now you will use them to print a summary.
- In check_compliance.py, scroll down to the print_report function.
- Find this line near the bottom of the function:
print("=" * 60 + "\n")
- Replace that line with the following summary block:
print("\n" + "-" * 60)
print("SUMMARY")
print(f" Total devices: {total_devices}")
print(f" Compliant: {compliant_count}")
print(f" Non-compliant: {total_devices - compliant_count}")
if violation_counts:
most_common = max(violation_counts, key=violation_counts.get)
print(f" Most common violation: {most_common} ({violation_counts[most_common]} devices)")
print("=" * 60 + "\n")
What does the summary add?
- total_devices and compliant_count give you an at-a-glance fleet health score.
- violation_counts tracks how many devices failed each rule. The max() call finds the most common violation so you know where to focus remediation.
- Save check_compliance.py.
- Run the checker again to see the new summary:
python check_compliance.py output/
You should see all 6 devices with [PASS] status, plus a summary showing 6 compliant and 0 non-compliant.
Now test that your checker actually catches violations. Before you run the next command, what do you think will happen if an NTP line is missing from a config?
- Simulate a manual change by removing an NTP line from one config:
sed -i '' '/ntp server 10.100.1.2/d' output/us-west-2-spine-01.cfg
- Re-run the compliance checker:
python check_compliance.py output/
Now us-west-2-spine-01.cfg shows [FAIL] with the violation message: NTP servers must be configured: Missing: ntp server 10.100.1.2. The summary shows 5 compliant, 1 non-compliant.
Restore the config for future steps
Regenerate your configs so everything is back to a clean state for the next step:
python generate.py
✔️ Awesome, I've got everything!
Great. Double check you have saved both data/standards.yaml and check_compliance.py.
ⓧ I'd like to double check the full code
Your check_compliance.py should contain these sections in order. Scroll up through your file and confirm each one is present:
- Imports (os, sys, yaml).
- load_standards() function.
- check_device_compliance() function with required_lines and forbidden_lines checks.
- check_directory() function that scans .cfg files.
- print_report() function with per-device output AND summary statistics.
- main() function and if __name__ == "__main__" block.
Your data/standards.yaml should have 5 rules: ntp_servers, ssh_version, snmp_security, banner, and vty_transport.
Your compliance checker can now validate any config file against your standards and flag exactly what drifted. Next up, you will wrap both the generator and the checker into a single professional CLI tool with subcommands and filters.
Create the Unified CLI Tool
You now have two separate scripts: generate.py for creating configs and check_compliance.py for validating them. Running separate scripts for each task is clunky when you need to generate and validate in one pass.
A real deployment tool offers a single entry point with subcommands. In this step, you will build a unified CLI tool using Python's built-in argparse module. This is exactly how internal tooling works at companies like Amazon.
In this step, get ready to:
- Create ndt.py with argparse subcommands for generate and check.
- Add a --datacenter filter and --verbose flag for targeted generation.
- Add a full subcommand that generates configs and immediately runs compliance checks.
Create ndt.py with argparse and the check subcommand
The argparse module is built into Python's standard library. It lets you define subcommands (like git commit or docker build) so your tool feels like a professional CLI.
- In PyCharm, create a new file called ndt.py in the root of your network-config-generator folder (the same level as generate.py).
- Add the imports and the check command handler by pasting this code:
import argparse
import sys
from generate import load_inventory, generate_configs
from check_compliance import check_directory, print_report
def cmd_check(args):
config_dir = args.directory
all_results = check_directory(config_dir)
print_report(all_results)
What does this code do?
The imports pull in your existing functions from generate.py and check_compliance.py. Your new CLI tool reuses the logic you already built instead of duplicating it.
The cmd_check function is a thin wrapper that takes the parsed arguments, extracts the directory path, and calls your existing compliance functions.
- Below cmd_check, add the main() function that wires up argparse with the check subcommand:
def main():
parser = argparse.ArgumentParser(
prog="ndt",
description="Network Deployment Tool - Generate and validate network configurations",
)
subparsers = parser.add_subparsers(dest="command", help="Available commands")
check_parser = subparsers.add_parser("check", help="Check configuration compliance")
check_parser.add_argument("directory", help="Directory containing config files")
check_parser.set_defaults(func=cmd_check)
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(1)
args.func(args)
if __name__ == "__main__":
main()
What does this code do?
The ArgumentParser creates a top-level CLI tool named ndt. The add_subparsers() call enables subcommands so you can type python ndt.py check or python ndt.py generate.
The set_defaults(func=cmd_check) pattern links each subcommand to its handler function. When the user runs a subcommand, argparse calls the right function automatically.
- Save ndt.py.
- Test the check subcommand by running this in your terminal:
python ndt.py check output/
You should see the same compliance report you saw before, but now triggered through your unified CLI tool instead of calling check_compliance.py directly.
Seeing a ModuleNotFoundError?
Make sure your virtual environment is activated. The from generate import ... line imports from your local files, not a package. Confirm generate.py and check_compliance.py are in the same folder as ndt.py.
help me debug a ModuleNotFoundError when running ndt.py
Add the generate subcommand with --datacenter and --verbose
The generate subcommand needs two optional flags. The --datacenter flag lets you generate configs for a single datacenter instead of all of them. The --verbose flag lists every generated file.
- In ndt.py, add the cmd_generate function above cmd_check:
def cmd_generate(args):
inventory = load_inventory("data/inventory.yaml")
generated = generate_configs(
inventory, "output", datacenter_filter=args.datacenter
)
print(f"Generated {len(generated)} configuration files:")
for filename in generated:
if args.verbose:
print(f" output/{filename}")
if not args.verbose:
print(f" (use --verbose to list all files)")
What does this code do?
This function passes args.datacenter directly to your existing generate_configs() function as the datacenter_filter parameter. When the user does not supply --datacenter, argparse sets it to None, which means all datacenters get generated.
The --verbose flag controls output detail. Without it, the tool just shows a count and a hint. With it, every filename is listed.
- Now register the generate subcommand in main(). Find the line that says check_parser = subparsers.add_parser("check" and add this code above it:
gen_parser = subparsers.add_parser("generate", help="Generate device configurations")
gen_parser.add_argument("--datacenter", help="Generate for a specific datacenter only")
gen_parser.add_argument("--verbose", action="store_true", help="Show detailed output")
gen_parser.set_defaults(func=cmd_generate)
- Save ndt.py.
- Test the datacenter filter by generating configs for only us-west-2:
python ndt.py generate --datacenter us-west-2 --verbose
You should see 4 files generated (only the us-west-2 devices) with each filename listed.
Seeing all 6 files instead of 4?
Check that you spelled --datacenter us-west-2 exactly as it appears in your inventory.yaml (under the name field). The filter does an exact string match.
help me debug the --datacenter filter in my ndt.py CLI tool
Add the full subcommand
The full subcommand combines both operations: generate all configs, then immediately run compliance checks against them. One command, one unified report.
- In ndt.py, add the cmd_full function below cmd_check:
def cmd_full(args):
print("Step 1: Generating configurations...")
inventory = load_inventory("data/inventory.yaml")
generated = generate_configs(
inventory, "output", datacenter_filter=args.datacenter
)
print(f" Generated {len(generated)} files.\n")
print("Step 2: Running compliance checks...")
all_results = check_directory("output")
print_report(all_results)
What does this code do?
This function orchestrates both operations in sequence. It generates configs first, then passes the output directory straight to your compliance checker. The print statements give the user a clear progress trail.
The full subcommand also supports --datacenter so you can generate and validate a single datacenter in one pass.
- Register the full subcommand in main(). Find the line args = parser.parse_args() and add this code above it:
full_parser = subparsers.add_parser("full", help="Generate configs then check compliance")
full_parser.add_argument("--datacenter", help="Generate for a specific datacenter only")
full_parser.set_defaults(func=cmd_full)
- Save ndt.py.
Before you run this, what do you expect to see? The tool should generate all 6 configs, then immediately show a compliance report for each one.
- Run the full pipeline with this command:
python ndt.py full
You should see "Step 1: Generating configurations..." followed by the file count, then "Step 2: Running compliance checks..." followed by the full compliance report with all 6 devices showing PASS.
Seeing an AttributeError about 'datacenter'?
Make sure your full_parser includes the --datacenter argument. The cmd_full function accesses args.datacenter, so the parser must define it even though it is optional.
help me fix an AttributeError in my ndt.py full subcommand
- Try the datacenter filter on the full pipeline:
python ndt.py generate --datacenter us-west-2
- Then validate just those configs:
python ndt.py check output/
Both commands work from a single tool with clear output. You have replaced two separate scripts with one professional CLI.
Why argparse instead of Click or Typer?
The argparse module is built into Python's standard library. No additional dependency needed. In an interview context, building a CLI tool from scratch (without third-party frameworks) demonstrates you understand how argument parsing works under the hood.
✔️ Awesome, I've got everything!
Great. Double check you have saved ndt.py with all three subcommands working.
ⓧ I'd like to double check the full code
Here is the complete ndt.py file. Scroll through and confirm each section is present in order: imports, cmd_generate, cmd_check, cmd_full, then main().
import argparse
import sys
from generate import load_inventory, generate_configs
from check_compliance import check_directory, print_report
def cmd_generate(args):
inventory = load_inventory("data/inventory.yaml")
generated = generate_configs(
inventory, "output", datacenter_filter=args.datacenter
)
print(f"Generated {len(generated)} configuration files:")
for filename in generated:
if args.verbose:
print(f" output/{filename}")
if not args.verbose:
print(f" (use --verbose to list all files)")
def cmd_check(args):
config_dir = args.directory
all_results = check_directory(config_dir)
print_report(all_results)
def cmd_full(args):
print("Step 1: Generating configurations...")
inventory = load_inventory("data/inventory.yaml")
generated = generate_configs(
inventory, "output", datacenter_filter=args.datacenter
)
print(f" Generated {len(generated)} files.\n")
print("Step 2: Running compliance checks...")
all_results = check_directory("output")
print_report(all_results)
def main():
parser = argparse.ArgumentParser(
prog="ndt",
description="Network Deployment Tool - Generate and validate network configurations",
)
subparsers = parser.add_subparsers(dest="command", help="Available commands")
gen_parser = subparsers.add_parser("generate", help="Generate device configurations")
gen_parser.add_argument("--datacenter", help="Generate for a specific datacenter only")
gen_parser.add_argument("--verbose", action="store_true", help="Show detailed output")
gen_parser.set_defaults(func=cmd_generate)
check_parser = subparsers.add_parser("check", help="Check configuration compliance")
check_parser.add_argument("directory", help="Directory containing config files")
check_parser.set_defaults(func=cmd_check)
full_parser = subparsers.add_parser("full", help="Generate configs then check compliance")
full_parser.add_argument("--datacenter", help="Generate for a specific datacenter only")
full_parser.set_defaults(func=cmd_full)
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(1)
args.func(args)
if __name__ == "__main__":
main()
Your toolkit is now a single, professional CLI tool. One entry point, three subcommands, and the ability to target specific datacenters. This is exactly how internal deployment tools are structured at scale.
Secret mission
Add Drift Detection Mode
Your compliance checker validates configs against standards. But what about unauthorized manual changes? In production, engineers SSH into devices and make quick fixes that drift away from the intended config. Can you build a tool that catches those changes?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally on your machine, so there are no ongoing costs or cloud resources to worry about. Decide whether to keep your project files to continue building on them, or delete them to free up disk space.
Resources you used:
- Project folder on your Desktop containing all Python scripts, templates, and data files.
- Python virtual environment (venv/) with Jinja2 and PyYAML packages.
- Generated configuration files in the output/ directory.
Keep everything running
No action needed. Choose this if you plan to keep building on this project, add more device roles, or push it to GitHub as a portfolio piece.
- Your project folder stays on your Desktop, ready to use anytime.
- The virtual environment stays active with Jinja2 and PyYAML installed.
- Generated configs in output/ remain available for compliance checks or drift detection.
Pause - I'll come back to this later
Free up some disk space by removing the generated output files and the virtual environment. Your source code stays intact so you can recreate everything later.
- Delete the generated configs and the virtual environment by running these commands from inside your project folder:
rm -rf output/
rm -rf venv/
- When you come back, recreate the virtual environment and reinstall packages:
python3 -m venv venv
source venv/bin/activate
pip install Jinja2==3.1.6 PyYAML==6.0.3
- Then regenerate your configs with python ndt.py generate and you are back to where you left off.
Delete - I don't want to use this again
Remove all project files and start fresh. This deletes everything you built in this project.
- First, deactivate your virtual environment if it is still active:
deactivate
- Navigate to your Desktop and delete the entire project folder:
cd ~/Desktop
rm -rf network-deployment-tool
- Confirm the folder is gone by running:
ls
- You should no longer see the network-deployment-tool folder in the output. Everything is cleaned up.
Nice Work!
Nice Work!
You just built a complete network deployment automation toolkit from scratch. One command now generates standards-compliant Cisco IOS configurations for an entire multi-datacenter inventory, and another validates those configs against your defined network standards.
You've learned how to:
- Build a data-driven configuration generator using Jinja2 templates and YAML inventory data that scales from 5 devices to 500 without changing a single line of code.
- Create role-based templates that produce valid Cisco IOS configurations for spine switches, leaf switches, and edge routers across multiple datacenters.
- Implement a compliance checker and package it alongside your generator into a professional CLI tool with subcommands, datacenter filters, and formatted reporting.
- Secret Mission: Added drift detection to compare actual device configurations against their intended state and highlight exact line-level differences.
Ready to quiz yourself?