Automate a Clinic Network with Python
Build a Python tool that automates VLAN configuration for a cardiology clinic.
Introduction
30 Second Summary
Small medical clinics handle sensitive patient data every day, from ECG readings to billing records. Keeping that data safe starts with how the network connecting those machines is configured.
In this project, you will build a Python tool that automates the network switch configuration for a cardiology clinic, separating front desk traffic from clinical workstation traffic using VLANs.
What You'll Build
A CLI-based Python application that generates, validates, and deploys network switch configurations for a two-workstation cardiology clinic.
By the end of this project, you'll have:
- A YAML inventory describing the clinic's network devices and workstation connections.
- Jinja2 templates that generate VLAN-segmented switch configurations separating administrative traffic from clinical traffic.
- A security validator that checks configurations against healthcare network best practices before deployment, and a Netmiko connector that can push configs to a real switch via SSH.
- Secret Mission: Add a compliance audit feature that detects configuration drift on the clinic switch.
Are there any prerequisites?
This project assumes basic Python knowledge including variables, loops, functions, and dictionaries. You do not need any networking hardware to complete it.
Before We Start
To automate a cardiology clinic's network, you need a Python environment with a few specialized libraries.
In this step, you will install Python, create an isolated project environment, and pull in the dependencies that power config generation, validation, and SSH automation.
In this step, get ready to:
- Install Python 3.9+ and verify it works.
- Create your project directory and virtual environment.
- Install Netmiko, Jinja2, and PyYAML.
Install Python 3.9+
This project requires Python version 3.9 or higher. The libraries you will install later depend on features introduced in Python 3.9.
- Check your Python version by running this command in your terminal:
macOS
python3 --version
You should see output like Python 3.12.4 or any version 3.9 or above.
Windows
python --version
You should see output like Python 3.12.4 or any version 3.9 or above.
✔️ I see version 3.9 or higher
Python is ready. Move on to the next substep.
ⓧ I see an older version
You have Python installed, but it is below 3.9. You need to install a newer version.
- Visit python.org/downloads and download the latest Python 3 installer for your operating system.
- Run the installer. On Windows, check the box that says Add Python to PATH before clicking Install Now.
- Close and reopen your terminal, then re-run the version check command above.
macOS version shadowing
On macOS, the system python3 may still resolve to the old version after installing a new one. If this happens, try running python3.12 --version (replace 3.12 with the version you installed) to confirm the new install works. Then use that binary for the rest of the project.
ⓧ Command not found
Python is not installed on your machine yet.
- Visit python.org/downloads and download the latest Python 3 installer for your operating system.
- Run the installer. On Windows, check the box that says Add Python to PATH before clicking Install Now.
- Close and reopen your terminal after installation finishes.
- Re-run the version check command above to confirm Python is available.
Still seeing command not found?
On macOS, try python3 --version instead. On Windows, make sure you checked Add Python to PATH during installation. If you missed it, re-run the installer, select Modify, and enable the PATH option.
help me troubleshoot Python not being found after installation
Create your project directory and virtual environment
A virtual environment keeps your project's dependencies separate from other Python projects on your machine. This prevents version conflicts between projects.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Visual Studio Code and press Enter to open it.
What is VS Code?
VS Code is a free code editor from Microsoft. If you do not have it installed, download it from code.visualstudio.com.
- Open a terminal inside VS Code by pressing Ctrl+` (backtick key, below Escape).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir cardio-clinic-network
cd cardio-clinic-network
- Create a virtual environment by running this command:
macOS
python3 -m venv venv
Windows
python -m venv venv
- Activate the virtual environment:
macOS
source venv/bin/activate
Windows
venv\Scripts\activate
You should see (venv) appear at the start of your terminal prompt. This confirms the virtual environment is active.
- Open this project folder in VS Code by pressing Cmd+O (macOS) or Ctrl+O (Windows), then selecting the cardio-clinic-network folder on your Desktop.
Why a virtual environment?
Without a virtual environment, installing packages changes your system-wide Python setup. If two projects need different versions of the same library, they will conflict.
The venv folder holds a self-contained Python installation. When activated, pip install puts packages only inside this folder.
Don't see (venv) in your prompt?
Make sure you ran the activate command for your operating system. On Windows, if you see a permissions error, try running Set-ExecutionPolicy -Scope CurrentUser RemoteSigned first, then retry the activate command.
help me activate my Python virtual environment
Install project dependencies
This project uses three libraries: Netmiko for SSH connections to network devices, Jinja2 for generating switch configurations from templates, and PyYAML for reading the clinic inventory file.
- Install all three libraries by running this command:
pip install netmiko==4.7.0 jinja2==3.1.6 pyyaml==6.0.3
What do these libraries do?
Netmiko handles SSH connections to network switches. It knows how to navigate Cisco IOS prompts, enter configuration mode, and send commands without you managing raw SSH state.
Jinja2 is a templating engine. You write a template with placeholders, feed it data, and it generates complete switch configuration files. Ansible uses the same approach for network automation.
PyYAML reads YAML files into Python dictionaries. You will use it to load the clinic network inventory that describes your switch and workstations.
You should see output ending with Successfully installed followed by the package names.
Installation failed or timed out?
Make sure (venv) appears in your prompt. If not, re-run the activate command from the previous substep. If you see a network timeout, check your internet connection and retry.
help me fix my pip install failure
Your Python environment is ready with all the tools you need. Next up, you will define the cardiology clinic's network inventory and start building the automation pipeline.
Set Up Your Python Environment
Your Python environment is ready with a virtual environment and all the dependencies you need. Now it is time to organize your project into a clear file structure.
Each module in this project handles a different part of the automation pipeline. Setting up empty files now gives you a map of the tool you are about to build.
In this step, get ready to:
- Create directories for your inventory files, templates, and generated output.
- Create empty Python module files for each part of the automation tool.
Create the project directories
Your project needs three subdirectories. Each one stores a different type of file the tool produces or consumes.
- Create the three project directories by running these commands in your terminal:
macOS
mkdir inventory templates output
Windows
mkdir inventory, templates, output
What are these directories for?
- inventory/ stores the YAML file describing your clinic's network devices and layout.
- templates/ stores Jinja2 template files that generate switch configurations.
- output/ stores the generated configuration files ready for deployment.
- Confirm the directories exist by running:
ls
You should see inventory, templates, and output listed in the output.
Don't see all three directories?
Make sure your terminal is inside your project folder (the one containing your venv directory).
If a directory is missing, re-run the mkdir command for just that directory name.
Help me figure out why my directories were not created.
Create the Python module files
Each Python file you create will become a module in your automation tool. Creating them now gives you a clear overview of the project's architecture.
- Create all five Python module files by running these commands:
macOS
touch inventory_loader.py config_generator.py config_validator.py device_connector.py main.py
Windows
New-Item inventory_loader.py, config_generator.py, config_validator.py, device_connector.py, main.py -ItemType File
What does each module do?
- inventory_loader.py reads the clinic's network layout from a YAML file.
- config_generator.py uses Jinja2 templates to produce switch configurations.
- config_validator.py checks generated configs against healthcare security rules.
- device_connector.py connects to the clinic switch via SSH using Netmiko.
- main.py ties everything together as a CLI entry point.
- Verify all files were created by running:
ls
You should see all five .py files alongside the three directories you created earlier.
Missing one or more files?
Check that your terminal is still inside the project folder. On Windows, make sure you used New-Item with the -ItemType File flag.
Help me create the missing Python files for my project.
Your project skeleton is ready. Next up, you will fill in the clinic network inventory with real device data.
Define the Clinic Network Inventory
Your Python environment is ready and your project structure is in place. Now it's time to describe the actual network your tool will manage.
Every network automation tool needs a source of truth. This is a single file that describes the devices, connections, and rules in the network. You'll define this source of truth for the Heartwell Cardiology Clinic using YAML, the same format used by production tools like Ansible and Nornir.
In this step, get ready to:
- Write a YAML inventory describing the clinic's switch, VLANs, and workstations.
- Build a Python module that loads and validates the inventory data.
Write the clinic network inventory
A network inventory describes every device your tool manages. For the cardiology clinic, that means one managed switch, the VLANs it enforces, and the two workstations plugged into it.
YAML is the standard format for network inventories because it's human-readable and maps cleanly to Python dictionaries.
- In the VS Code Explorer sidebar, right-click the inventory folder.
- Select New File and name it clinic_network.yaml.
- Paste the following inventory into the file:
---
clinic:
name: Heartwell Cardiology Clinic
location: Suite 200, Medical Plaza
switch:
hostname: cardio-clinic-sw01
host: 192.168.1.1
device_type: cisco_ios
username: admin
password: your-password-here
secret: your-enable-secret-here
management_vlan: 99
management_ip: 192.168.1.1
management_subnet: 255.255.255.0
vlans:
- id: 10
name: ADMIN
description: Front desk scheduling and billing traffic
- id: 20
name: CLINICAL
description: Cardiologist ECG and patient record traffic
- id: 99
name: MANAGEMENT
description: Switch management access
workstations:
- name: Front Desk PC
port: GigabitEthernet0/1
description: Front Desk - Scheduling and Billing
vlan: 10
mac_limit: 1
role: administrative
- name: Cardiologist Workstation
port: GigabitEthernet0/2
description: Cardiology - ECG and Patient Records
vlan: 20
mac_limit: 1
role: clinical
What does this inventory describe?
This file is the complete picture of the clinic's network in one place.
- The switch section describes the clinic's managed switch (hostname, IP address, credentials, device type for Netmiko).
- The vlans section defines three VLANs: ADMIN (10) for front desk traffic, CLINICAL (20) for cardiologist traffic, and MANAGEMENT (99) for switch access.
- The workstations section maps each physical port to a workstation, its VLAN, and a MAC address limit of 1 for port security.
Why separate VLANs for admin and clinical?
VLAN segmentation keeps the front desk's scheduling and billing traffic completely isolated from the cardiologist's ECG and patient record traffic. Even though both workstations share the same switch, traffic from one VLAN cannot reach the other without explicit routing.
This is a baseline security practice in healthcare networks. It limits the blast radius if one workstation is compromised.
- Save inventory/clinic_network.yaml.
- Confirm the file exists by running this command in your terminal:
ls inventory/
You should see clinic_network.yaml listed in the output.
Don't see the file?
- Make sure you created the file inside the inventory/ folder, not in the project root.
- Check that the filename is exactly clinic_network.yaml with no extra spaces or typos.
Help me find my YAML inventory file
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
---
clinic:
name: Heartwell Cardiology Clinic
location: Suite 200, Medical Plaza
switch:
hostname: cardio-clinic-sw01
host: 192.168.1.1
device_type: cisco_ios
username: admin
password: your-password-here
secret: your-enable-secret-here
management_vlan: 99
management_ip: 192.168.1.1
management_subnet: 255.255.255.0
vlans:
- id: 10
name: ADMIN
description: Front desk scheduling and billing traffic
- id: 20
name: CLINICAL
description: Cardiologist ECG and patient record traffic
- id: 99
name: MANAGEMENT
description: Switch management access
workstations:
- name: Front Desk PC
port: GigabitEthernet0/1
description: Front Desk - Scheduling and Billing
vlan: 10
mac_limit: 1
role: administrative
- name: Cardiologist Workstation
port: GigabitEthernet0/2
description: Cardiology - ECG and Patient Records
vlan: 20
mac_limit: 1
role: clinical
Build the inventory loader module
The YAML file is the data. Now you need a Python module that reads it, checks that nothing is missing, and hands the structured data to the rest of your tool.
- Open inventory_loader.py in VS Code (it's in your project root).
- Add the module header with imports and validation constants:
"""Load and validate the cardiology clinic network inventory."""
import sys
from pathlib import Path
import yaml
REQUIRED_SWITCH_FIELDS = ["hostname", "host", "device_type", "username", "password"]
REQUIRED_WORKSTATION_FIELDS = ["name", "port", "description", "vlan", "mac_limit"]
What does this code do?
- yaml is the PyYAML library that parses your YAML inventory into a Python dictionary.
- REQUIRED_SWITCH_FIELDS lists the fields every switch entry must have for Netmiko to connect.
- REQUIRED_WORKSTATION_FIELDS lists what each workstation entry needs for config generation later.
You won't see a change yet. The next chunk adds the functions that use these constants.
- Below the constants, add the two core functions that load and validate the inventory:
def load_inventory(inventory_path):
path = Path(inventory_path)
if not path.exists():
raise FileNotFoundError(f"Inventory file not found: {inventory_path}")
with open(path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
if not data:
raise ValueError("Inventory file is empty")
validate_inventory(data)
return data
def validate_inventory(data):
if "switch" not in data:
raise ValueError("Inventory must contain a 'switch' section")
if "vlans" not in data:
raise ValueError("Inventory must contain a 'vlans' section")
if "workstations" not in data:
raise ValueError("Inventory must contain a 'workstations' section")
switch = data["switch"]
missing = [f for f in REQUIRED_SWITCH_FIELDS if f not in switch]
if missing:
raise ValueError(f"Switch missing required fields: {', '.join(missing)}")
for ws in data["workstations"]:
missing = [f for f in REQUIRED_WORKSTATION_FIELDS if f not in ws]
if missing:
raise ValueError(
f"Workstation '{ws.get('name', 'UNKNOWN')}' missing fields: "
f"{', '.join(missing)}"
)
What do these functions do?
- load_inventory() opens the YAML file, parses it with yaml.safe_load(), then calls validate_inventory() before returning the data.
- validate_inventory() checks that the three required sections exist (switch, vlans, workstations) and that each entry has all required fields.
- If anything is missing, the module raises a ValueError with a message naming exactly what's wrong. This catches typos in the YAML before they cause confusing errors later.
You won't see a change yet. The next chunk adds a test runner so you can verify it works.
- Below the validate_inventory function, add the test runner at the bottom of the file:
if __name__ == "__main__":
try:
data = load_inventory("inventory/clinic_network.yaml")
print(f"Clinic: {data['clinic']['name']}")
print(f"Switch: {data['switch']['hostname']} ({data['switch']['host']})")
print(f"VLANs: {len(data['vlans'])}")
print(f"Workstations: {len(data['workstations'])}")
for ws in data["workstations"]:
print(f" - {ws['name']} on {ws['port']} (VLAN {ws['vlan']})")
except (FileNotFoundError, ValueError) as e:
print(f"Error: {e}")
sys.exit(1)
What does this block do?
The if __name__ == "__main__" block runs only when you execute the file directly. It loads the clinic inventory and prints a summary of the switch, VLANs, and workstations. This lets you verify the YAML file and loader work together before wiring them into the full tool.
- Save inventory_loader.py.
- Run the loader to verify it reads the clinic inventory:
python inventory_loader.py
You should see output like this:
Clinic: Heartwell Cardiology Clinic
Switch: cardio-clinic-sw01 (192.168.1.1)
VLANs: 3
Workstations: 2
- Front Desk PC on GigabitEthernet0/1 (VLAN 10)
- Cardiologist Workstation on GigabitEthernet0/2 (VLAN 20)
Seeing a ModuleNotFoundError for yaml?
- Make sure your virtual environment is active. You should see (venv) at the start of your terminal prompt.
- If the environment is active but yaml is still not found, reinstall with pip install pyyaml==6.0.3.
- If you see Inventory file not found, make sure you're running the command from your project root (the folder containing both inventory/ and inventory_loader.py).
Help me fix my inventory loader
✔️ Awesome, I've got everything!
Great. Double check you've saved inventory_loader.py before moving on.
ⓧ I'd like to double check the full code
"""Load and validate the cardiology clinic network inventory."""
import sys
from pathlib import Path
import yaml
REQUIRED_SWITCH_FIELDS = ["hostname", "host", "device_type", "username", "password"]
REQUIRED_WORKSTATION_FIELDS = ["name", "port", "description", "vlan", "mac_limit"]
def load_inventory(inventory_path):
path = Path(inventory_path)
if not path.exists():
raise FileNotFoundError(f"Inventory file not found: {inventory_path}")
with open(path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
if not data:
raise ValueError("Inventory file is empty")
validate_inventory(data)
return data
def validate_inventory(data):
if "switch" not in data:
raise ValueError("Inventory must contain a 'switch' section")
if "vlans" not in data:
raise ValueError("Inventory must contain a 'vlans' section")
if "workstations" not in data:
raise ValueError("Inventory must contain a 'workstations' section")
switch = data["switch"]
missing = [f for f in REQUIRED_SWITCH_FIELDS if f not in switch]
if missing:
raise ValueError(f"Switch missing required fields: {', '.join(missing)}")
for ws in data["workstations"]:
missing = [f for f in REQUIRED_WORKSTATION_FIELDS if f not in ws]
if missing:
raise ValueError(
f"Workstation '{ws.get('name', 'UNKNOWN')}' missing fields: "
f"{', '.join(missing)}"
)
if __name__ == "__main__":
try:
data = load_inventory("inventory/clinic_network.yaml")
print(f"Clinic: {data['clinic']['name']}")
print(f"Switch: {data['switch']['hostname']} ({data['switch']['host']})")
print(f"VLANs: {len(data['vlans'])}")
print(f"Workstations: {len(data['workstations'])}")
for ws in data["workstations"]:
print(f" - {ws['name']} on {ws['port']} (VLAN {ws['vlan']})")
except (FileNotFoundError, ValueError) as e:
print(f"Error: {e}")
sys.exit(1)
Your clinic's network is now fully described in code. Next up, you'll build Jinja2 templates that turn this inventory into real switch configurations.
Build Clinic Configuration Templates
Your inventory describes the clinic's network layout, but the switch needs actual configuration commands. You need a way to transform that YAML data into valid switch configuration syntax.
In this step, you will create Jinja2 templates that generate switch commands from your inventory data. Then you will build a Python module that renders those templates and saves the complete switch configuration to a file.
In this step, get ready to:
- Create a VLAN configuration template that defines the clinic's network segments.
- Create a port security template that locks down each workstation port.
- Build a config generator module that renders templates and writes the switch configuration.
Create the VLAN template
VLANs segment the clinic's network into isolated groups. The front desk and cardiologist workstation each need their own VLAN so clinical traffic never mixes with administrative traffic.
- In VS Code, right-click the templates folder in the Explorer sidebar.
- Click New File.
- Name the file vlans.j2 and press Enter.
- Paste the following template code into the file:
! =============================================
! VLAN Configuration - {{ clinic.name }}
! Location: {{ clinic.location }}
! Generated by Clinic Network Automation Tool
! =============================================
!
{% for vlan in vlans %}
vlan {{ vlan.id }}
name {{ vlan.name }}
!
{% endfor %}
What does this template do?
The {{ }} syntax inserts values from your inventory data. For example, {{ clinic.name }} pulls the clinic name from the YAML file.
The {% for vlan in vlans %} loop iterates over every VLAN in your inventory and generates a vlan command block for each one. Your inventory has three VLANs (ADMIN, CLINICAL, MANAGEMENT), so this template produces three blocks.
- Save templates/vlans.j2.
- Confirm the file exists by running:
ls templates/
You should see vlans.j2 listed in the output.
Don't see vlans.j2?
Make sure you created the file inside the templates/ folder, not in the project root. If the folder looks empty, check whether the file was saved to the wrong location.
Help me find my vlans.j2 file
Create the port security template
Each workstation port on the switch needs security controls. Port security limits how many devices can connect to a single port, preventing unauthorized devices from accessing the clinic network.
- Right-click the templates folder in the Explorer sidebar again.
- Click New File and name it port_config.j2.
- Paste the following template code:
! =============================================
! Port Configuration - {{ clinic.name }}
! Workstation Port Assignments
! Generated by Clinic Network Automation Tool
! =============================================
!
{% for workstation in workstations %}
interface {{ workstation.port }}
description {{ workstation.description }}
switchport mode access
switchport access vlan {{ workstation.vlan }}
switchport port-security
switchport port-security maximum {{ workstation.mac_limit }}
switchport port-security violation shutdown
spanning-tree portfast
no shutdown
!
{% endfor %}
! Shutdown unused ports
{% for port_num in range(3, 25) %}
interface GigabitEthernet0/{{ port_num }}
description UNUSED - DISABLED
shutdown
!
{% endfor %}
What does this template do?
The first loop configures each workstation port with its VLAN assignment and security settings. switchport port-security maximum {{ workstation.mac_limit }} limits the port to one MAC address, so only the authorized workstation can connect.
The switchport port-security violation shutdown command disables the port entirely if an unauthorized device connects. This is critical in a healthcare setting where rogue devices could access patient data.
The second loop shuts down ports GigabitEthernet0/3 through GigabitEthernet0/24. Since the clinic only uses two ports, every unused port gets disabled to prevent unauthorized access.
- Save templates/port_config.j2.
- Confirm both template files exist by running:
ls templates/
You should see both vlans.j2 and port_config.j2 listed.
Only see one file?
Double check you created port_config.j2 inside the templates/ directory. Run ls in the project root to check whether it was created there by mistake.
Help me find my missing template file
Why Jinja2 templates instead of string formatting?
Jinja2 is the same templating engine that Ansible uses for network automation across thousands of devices. By learning it here, you are building a skill that transfers directly to production network automation tools.
Templates also separate your logic from your data. You can update the inventory (add a workstation, change a VLAN) without touching the template code.
Build the config generator module
Now you need a Python module that loads these templates, feeds them your clinic inventory data, and writes the rendered switch configuration to a file.
- Open config_generator.py in VS Code (this file already exists in your project root as an empty file).
- Add the module docstring and imports by pasting the following:
"""Generate switch configurations for the cardiology clinic network."""
from pathlib import Path
from jinja2 import Environment, FileSystemLoader, TemplateNotFound
What are these imports?
Environment is the Jinja2 engine that loads and renders templates. FileSystemLoader tells it to look for template files in a directory on disk (your templates/ folder).
TemplateNotFound is an exception that fires if a template file is missing. You will use it to print a warning instead of crashing the whole tool.
- Below the imports, add the create_template_env function:
def create_template_env(templates_dir="templates"):
return Environment(
loader=FileSystemLoader(templates_dir),
trim_blocks=True,
lstrip_blocks=True,
)
What do trim_blocks and lstrip_blocks do?
Without these settings, Jinja2 leaves blank lines everywhere a {% %} tag appears. trim_blocks removes the newline after a block tag, and lstrip_blocks strips leading whitespace before block tags.
Together they produce clean, readable switch configuration output without extra blank lines.
- Below create_template_env, add the generate_clinic_config function:
def generate_clinic_config(inventory_data, templates_dir="templates"):
env = create_template_env(templates_dir)
config_sections = []
template_names = ["vlans.j2", "port_config.j2"]
for template_name in template_names:
try:
template = env.get_template(template_name)
rendered = template.render(**inventory_data)
config_sections.append(rendered)
except TemplateNotFound:
print(f" Warning: Template '{template_name}' not found, skipping.")
return "\n".join(config_sections)
What does this function do?
This function renders each template in order (VLANs first, then port config) by passing in the full inventory dictionary. The **inventory_data unpacking means your templates can access clinic, vlans, and workstations directly as top-level variables.
If a template file is missing, it prints a warning and continues rather than crashing. This makes the tool more resilient during development.
- Below generate_clinic_config, add the generate_and_save function and the __main__ block:
def generate_and_save(inventory_data, templates_dir="templates", output_dir="output"):
output_path = Path(output_dir)
output_path.mkdir(exist_ok=True)
hostname = inventory_data["switch"]["hostname"]
print(f"Generating config for {hostname}...")
print(f" Clinic: {inventory_data['clinic']['name']}")
print(f" Workstations: {len(inventory_data['workstations'])}")
config = generate_clinic_config(inventory_data, templates_dir)
config_file = output_path / f"{hostname}.cfg"
with open(config_file, "w", encoding="utf-8") as f:
f.write(config)
print(f" Saved to {config_file}")
print(f" Config size: {len(config.splitlines())} lines")
return config
if __name__ == "__main__":
from inventory_loader import load_inventory
data = load_inventory("inventory/clinic_network.yaml")
config = generate_and_save(data)
print("\nGenerated config preview (first 30 lines):")
print("-" * 50)
for line in config.splitlines()[:30]:
print(line)
What does generate_and_save do?
This function creates the output/ directory if it does not exist, generates the full config, and writes it to a .cfg file named after the switch hostname. For your clinic, it produces output/cardio-clinic-sw01.cfg.
The __main__ block at the bottom lets you test the module directly. It loads your clinic inventory, generates the config, and prints a preview of the first 30 lines.
- Save config_generator.py.
- Run the config generator to test it:
python config_generator.py
You should see output showing the clinic name, workstation count, the saved file path, and a preview of the generated switch commands including VLAN definitions and port security settings.
Seeing a ModuleNotFoundError?
If you see an error about jinja2 not being found, make sure your virtual environment is active. You should see (venv) at the start of your terminal prompt. If not, activate it with:
venv\Scripts\activate
If you see an error about inventory_loader, make sure you are running the command from your project root directory (the folder that contains both config_generator.py and inventory_loader.py).
Help me debug my config generator error
✔️ Awesome, I've got everything!
Great. Double check you have saved config_generator.py and that the output/cardio-clinic-sw01.cfg file was created.
ⓧ I'd like to double check the full code
"""Generate switch configurations for the cardiology clinic network."""
from pathlib import Path
from jinja2 import Environment, FileSystemLoader, TemplateNotFound
def create_template_env(templates_dir="templates"):
return Environment(
loader=FileSystemLoader(templates_dir),
trim_blocks=True,
lstrip_blocks=True,
)
def generate_clinic_config(inventory_data, templates_dir="templates"):
env = create_template_env(templates_dir)
config_sections = []
template_names = ["vlans.j2", "port_config.j2"]
for template_name in template_names:
try:
template = env.get_template(template_name)
rendered = template.render(**inventory_data)
config_sections.append(rendered)
except TemplateNotFound:
print(f" Warning: Template '{template_name}' not found, skipping.")
return "\n".join(config_sections)
def generate_and_save(inventory_data, templates_dir="templates", output_dir="output"):
output_path = Path(output_dir)
output_path.mkdir(exist_ok=True)
hostname = inventory_data["switch"]["hostname"]
print(f"Generating config for {hostname}...")
print(f" Clinic: {inventory_data['clinic']['name']}")
print(f" Workstations: {len(inventory_data['workstations'])}")
config = generate_clinic_config(inventory_data, templates_dir)
config_file = output_path / f"{hostname}.cfg"
with open(config_file, "w", encoding="utf-8") as f:
f.write(config)
print(f" Saved to {config_file}")
print(f" Config size: {len(config.splitlines())} lines")
return config
if __name__ == "__main__":
from inventory_loader import load_inventory
data = load_inventory("inventory/clinic_network.yaml")
config = generate_and_save(data)
print("\nGenerated config preview (first 30 lines):")
print("-" * 50)
for line in config.splitlines()[:30]:
print(line)
Your templates are working and the generator produces a full switch configuration file. Next up, you will add healthcare security validation to catch any insecure settings before they reach the switch.
Add Healthcare Security Validation
Your config generator now produces a complete switch configuration for the clinic. But generating a config is only half the job. In a healthcare environment, deploying an insecure configuration could expose patient data or violate network segmentation requirements.
In this step, you will build a security validator that checks every generated config against healthcare-specific rules before it can be deployed. The validator catches problems like missing port security, open trunk ports, and absent VLAN segmentation.
In this step, get ready to:
- Define security rules that catch insecure switch commands.
- Implement a validation report showing pass/fail results for each check.
- Wire validation into the generation pipeline so unsafe configs cannot deploy.
Create healthcare security rules
A healthcare switch config needs specific safeguards. Some commands should never appear because they weaken segmentation. Other patterns must always be present to protect patient data traffic.
- Open config_validator.py in VS Code.
- Add the following code that defines the forbidden commands, required patterns, and the main validation function:
"""Validate clinic switch configurations against healthcare security rules."""
FORBIDDEN_COMMANDS = [
"permit any any",
"no switchport port-security",
"switchport mode trunk",
]
REQUIRED_PATTERNS = [
"switchport port-security",
"switchport port-security violation shutdown",
"spanning-tree portfast",
"no shutdown",
]
def validate_config(config_text, clinic_name):
results = []
# Check that no forbidden commands exist in the config
for forbidden in FORBIDDEN_COMMANDS:
if forbidden in config_text:
results.append({"rule": f"No '{forbidden}' allowed", "status": "FAIL", "message": f"Found insecure command that could expose patient data", "severity": "CRITICAL"})
else:
results.append({"rule": f"No '{forbidden}' allowed", "status": "PASS", "message": "Not found in config", "severity": "CRITICAL"})
# Check that all required security patterns are present
for pattern in REQUIRED_PATTERNS:
if pattern in config_text:
results.append({"rule": f"Required: '{pattern}'", "status": "PASS", "message": "Found in config", "severity": "HIGH"})
else:
results.append({"rule": f"Required: '{pattern}'", "status": "FAIL", "message": "Missing required security control", "severity": "HIGH"})
# Verify VLAN segmentation between admin and clinical traffic
if "vlan 10" in config_text and "vlan 20" in config_text:
results.append({"rule": "VLAN segmentation defined", "status": "PASS", "message": "Admin (VLAN 10) and Clinical (VLAN 20) VLANs present", "severity": "CRITICAL"})
else:
results.append({"rule": "VLAN segmentation defined", "status": "FAIL", "message": "Missing VLAN segmentation between admin and clinical traffic", "severity": "CRITICAL"})
# Verify unused ports are shut down to prevent unauthorized access
if "shutdown" in config_text and "UNUSED" in config_text:
results.append({"rule": "Unused ports disabled", "status": "PASS", "message": "Unused ports are shut down", "severity": "HIGH"})
else:
results.append({"rule": "Unused ports disabled", "status": "FAIL", "message": "Unused ports should be disabled to prevent unauthorized access", "severity": "HIGH"})
# Verify the config is not empty
if len(config_text.strip()) == 0:
results.append({"rule": "Config not empty", "status": "FAIL", "message": "Generated config is empty", "severity": "CRITICAL"})
else:
results.append({"rule": "Config not empty", "status": "PASS", "message": f"Config has {len(config_text.splitlines())} lines", "severity": "INFO"})
passed = all(r["status"] == "PASS" for r in results)
return {"passed": passed, "results": results, "clinic": clinic_name}
What does this code do?
FORBIDDEN_COMMANDS lists commands that should never appear in a healthcare switch config. permit any any removes all access control. switchport mode trunk would allow workstation ports to carry traffic from all VLANs, defeating segmentation.
REQUIRED_PATTERNS lists commands that must be present. Port security limits which devices can connect. The validate_config() function checks the generated config text against both lists and returns a dictionary with a passed boolean and a results list of individual check outcomes.
Why validate before deploying?
In a healthcare network, pushing a misconfigured switch update could let front desk traffic reach clinical devices. Validating before deployment is a safety gate. The tool will refuse to push any config that fails these checks.
- Save config_validator.py.
You won't see a change yet. The next chunk adds the report printer that displays the results.
Seeing a syntax error?
The dictionary literals in the append() calls are long single lines. Make sure each results.append({...}) call has matching braces and quotes. Check that the f-string quotes do not conflict with the surrounding string delimiters.
Help me fix a syntax error in my config_validator.py file
Implement the validation report
Raw pass/fail data is not helpful on its own. A formatted report makes it easy to spot which checks failed and understand their severity.
- In config_validator.py, add the following function below the validate_config() function:
def print_validation_report(validation_result):
# Extract report data from the validation result dictionary
clinic = validation_result["clinic"]
passed = validation_result["passed"]
results = validation_result["results"]
# Print the report header with overall pass/fail status
status = "PASSED" if passed else "FAILED"
print(f"\n Security Validation for: {clinic}")
print(f" Overall Status: {status}")
print(f" {'=' * 50}")
# Print each individual check result with severity
for check in results:
icon = "+" if check["status"] == "PASS" else "x"
severity = check["severity"]
print(f" [{icon}] [{severity}] {check['rule']}")
print(f" {check['message']}")
# Print the summary score
pass_count = sum(1 for r in results if r["status"] == "PASS")
total = len(results)
print(f"\n Score: {pass_count}/{total} checks passed")
What does this code do?
print_validation_report() takes the dictionary returned by validate_config() and prints a readable report. Each check shows a + for pass or x for fail, along with its severity level (CRITICAL or HIGH).
- Save config_validator.py.
You won't see output yet. The next chunk adds the runner that ties generation and validation together.
Indentation looking off?
Make sure print_validation_report() is a top-level function (no indentation on the def line). It should sit at the same level as validate_config(), not nested inside it.
Help me fix indentation in my config_validator.py
Integrate validation into the pipeline
To make the validator useful as a standalone check, you will add a runner block that generates the config and immediately validates it. This is the same pattern the CLI will use later to refuse deployment when checks fail.
- At the bottom of config_validator.py, add the following code:
if __name__ == "__main__":
from inventory_loader import load_inventory
from config_generator import generate_and_save
# Generate the config, then run security validation
data = load_inventory("inventory/clinic_network.yaml")
config = generate_and_save(data)
print("\n--- Security Validation ---")
result = validate_config(config, data["clinic"]["name"])
print_validation_report(result)
What does this code do?
The __main__ block runs when you execute config_validator.py directly. It loads the clinic inventory, generates the switch config, then validates it and prints the report. This demonstrates the full pipeline: generate then validate.
- Save config_validator.py.
- Run the validator by entering this command in your terminal:
python config_validator.py
You should see the config generation output followed by a security validation report. All checks should show [+] (pass) with a final score of 9/9 checks passed.
Seeing a ModuleNotFoundError?
Make sure you are running the command from your project root directory (the folder containing config_validator.py, inventory_loader.py, and config_generator.py). Also confirm your virtual environment is still active.
Help me fix a ModuleNotFoundError when running config_validator.py
✔️ Awesome, I've got everything!
Great. Double check you have saved config_validator.py and the report shows 9/9 checks passed.
ⓧ I'd like to double check the full code
Here is the complete config_validator.py file:
"""Validate clinic switch configurations against healthcare security rules."""
FORBIDDEN_COMMANDS = [
"permit any any",
"no switchport port-security",
"switchport mode trunk",
]
REQUIRED_PATTERNS = [
"switchport port-security",
"switchport port-security violation shutdown",
"spanning-tree portfast",
"no shutdown",
]
def validate_config(config_text, clinic_name):
results = []
for forbidden in FORBIDDEN_COMMANDS:
if forbidden in config_text:
results.append({"rule": f"No '{forbidden}' allowed", "status": "FAIL", "message": f"Found insecure command that could expose patient data", "severity": "CRITICAL"})
else:
results.append({"rule": f"No '{forbidden}' allowed", "status": "PASS", "message": "Not found in config", "severity": "CRITICAL"})
for pattern in REQUIRED_PATTERNS:
if pattern in config_text:
results.append({"rule": f"Required: '{pattern}'", "status": "PASS", "message": "Found in config", "severity": "HIGH"})
else:
results.append({"rule": f"Required: '{pattern}'", "status": "FAIL", "message": "Missing required security control", "severity": "HIGH"})
if "vlan 10" in config_text and "vlan 20" in config_text:
results.append({"rule": "VLAN segmentation defined", "status": "PASS", "message": "Admin (VLAN 10) and Clinical (VLAN 20) VLANs present", "severity": "CRITICAL"})
else:
results.append({"rule": "VLAN segmentation defined", "status": "FAIL", "message": "Missing VLAN segmentation between admin and clinical traffic", "severity": "CRITICAL"})
if "shutdown" in config_text and "UNUSED" in config_text:
results.append({"rule": "Unused ports disabled", "status": "PASS", "message": "Unused ports are shut down", "severity": "HIGH"})
else:
results.append({"rule": "Unused ports disabled", "status": "FAIL", "message": "Unused ports should be disabled to prevent unauthorized access", "severity": "HIGH"})
if len(config_text.strip()) == 0:
results.append({"rule": "Config not empty", "status": "FAIL", "message": "Generated config is empty", "severity": "CRITICAL"})
else:
results.append({"rule": "Config not empty", "status": "PASS", "message": f"Config has {len(config_text.splitlines())} lines", "severity": "INFO"})
passed = all(r["status"] == "PASS" for r in results)
return {"passed": passed, "results": results, "clinic": clinic_name}
def print_validation_report(validation_result):
clinic = validation_result["clinic"]
passed = validation_result["passed"]
results = validation_result["results"]
status = "PASSED" if passed else "FAILED"
print(f"\n Security Validation for: {clinic}")
print(f" Overall Status: {status}")
print(f" {'=' * 50}")
for check in results:
icon = "+" if check["status"] == "PASS" else "x"
severity = check["severity"]
print(f" [{icon}] [{severity}] {check['rule']}")
print(f" {check['message']}")
pass_count = sum(1 for r in results if r["status"] == "PASS")
total = len(results)
print(f"\n Score: {pass_count}/{total} checks passed")
if __name__ == "__main__":
from inventory_loader import load_inventory
from config_generator import generate_and_save
data = load_inventory("inventory/clinic_network.yaml")
config = generate_and_save(data)
print("\n--- Security Validation ---")
result = validate_config(config, data["clinic"]["name"])
print_validation_report(result)
Your clinic now has an automated security gate. No configuration can reach the switch without passing every check first. Next up, you will build the Netmiko connector that talks to the actual switch hardware over SSH.
Build the Netmiko Switch Connector
Your config generator produces validated switch configurations, but those configs are just text files sitting on your computer. To actually apply them to the clinic's switch, you need a way to connect to the device remotely.
Network switches are managed over SSH connections. Netmiko is a Python library purpose-built for automating SSH sessions with network devices. In this step, you will build a connector module that can talk to the clinic's switch, retrieve its current config, and push new configurations.
In this step, get ready to:
- Create a connection function that SSHs into the clinic switch with error handling.
- Implement functions to retrieve and push switch configurations.
- Add a connection test mode that verifies switch reachability without making changes.
Create the SSH connection function
The connection function needs to handle real-world problems gracefully. A switch might be powered off, the credentials might be wrong, or the network might be unreachable. Each scenario needs a clear error message so you know exactly what to fix.
- In VS Code, open device_connector.py (the empty file you created earlier).
- Add the imports and the connect_to_switch() function by pasting this code:
"""Connect to the clinic network switch via SSH using Netmiko."""
from netmiko import ConnectHandler
from netmiko.exceptions import (
NetmikoAuthenticationException,
NetmikoTimeoutException,
)
def connect_to_switch(switch_data):
connection_params = {
"device_type": switch_data["device_type"],
"host": switch_data["host"],
"username": switch_data["username"],
"password": switch_data["password"],
"secret": switch_data.get("secret", ""),
"timeout": 10,
}
try:
print(f" Connecting to {switch_data['hostname']} ({switch_data['host']})...")
connection = ConnectHandler(**connection_params)
print(f" Connected successfully to {switch_data['hostname']}")
return connection
except NetmikoAuthenticationException:
print(f" ERROR: Authentication failed for {switch_data['hostname']}")
print(f" Check username/password in inventory file.")
return None
except NetmikoTimeoutException:
print(f" ERROR: Connection timed out for {switch_data['hostname']}")
print(f" Verify the switch is powered on and reachable at {switch_data['host']}")
return None
except Exception as e:
print(f" ERROR: Could not connect to {switch_data['hostname']}: {e}")
return None
What does this code do?
The imports pull in Netmiko's ConnectHandler (which manages SSH sessions) and two specific exceptions for targeted error handling.
- connection_params builds a dictionary from the inventory's switch data. Netmiko uses device_type to know which CLI prompts to expect (Cisco IOS in this case).
- NetmikoAuthenticationException catches wrong username or password. NetmikoTimeoutException catches unreachable devices.
- The function returns the connection object on success, or None on failure. This lets calling code check the result before proceeding.
Why Netmiko instead of raw SSH?
Netmiko is built on top of Paramiko (Python's SSH library) but adds device-type awareness. It automatically handles CLI prompts, enters configuration mode when needed, and manages session state. Raw Paramiko would require you to write all of that yourself.
Netmiko is the standard starting point for network automation in Python. Tools like NAPALM and Ansible's network modules use Netmiko internally.
- Save device_connector.py.
You will not see visible output yet. The next chunks add the functions that use this connection.
Implement config retrieval and deployment functions
Once connected, you need two operations: pulling the current running config from the switch (to inspect or audit it), and pushing new config commands (to apply your generated configuration).
- In device_connector.py, add the following two functions below connect_to_switch():
def get_running_config(connection):
output = connection.send_command("show running-config")
return output
def push_config(connection, config_commands):
output = connection.send_config_set(config_commands)
return output
What does this code do?
- send_command() sends a single command and waits for the output. show running-config is the Cisco IOS command that displays the switch's active configuration.
- send_config_set() takes a list of commands and automatically enters configuration mode on the switch, sends each command, then exits config mode. This is how you apply VLAN and port changes.
- Save device_connector.py.
You will not see a change yet. The next chunk adds the test mode that exercises these functions.
Add a connection test mode
You likely do not have a physical Cisco switch on your desk. The test mode verifies reachability without making changes, and handles the expected "unreachable" state gracefully so the rest of the tool still works.
- In device_connector.py, add the test_connection() function below push_config():
def test_connection(switch_data):
result = {
"hostname": switch_data["hostname"],
"host": switch_data["host"],
"reachable": False,
"message": "",
}
connection = connect_to_switch(switch_data)
if connection:
result["reachable"] = True
result["message"] = "Switch is reachable and credentials are valid"
connection.disconnect()
else:
result["message"] = (
"Switch is not reachable. This is expected if you do not have "
"a physical switch connected. The generate and validate commands "
"work without a live switch."
)
return result
What does this code do?
- The result dictionary tracks the switch hostname, IP, whether it was reachable, and a human-readable message.
- If connect_to_switch() succeeds, it immediately disconnects. The goal is to verify connectivity, not hold an open session.
- If the connection fails, the message explicitly tells the user this is expected without hardware. The tool's other commands (generate, validate) work entirely offline.
- Now add the if __name__ runner block at the bottom of device_connector.py:
if __name__ == "__main__":
from inventory_loader import load_inventory
data = load_inventory("inventory/clinic_network.yaml")
print(f"Testing connection to clinic switch: {data['switch']['hostname']}\n")
result = test_connection(data["switch"])
status = "REACHABLE" if result["reachable"] else "UNREACHABLE"
print(f"\n Result: {status}")
print(f" {result['message']}")
What does this code do?
This runner loads the clinic inventory, attempts to connect to the switch defined in it, and prints whether the switch is REACHABLE or UNREACHABLE. Since you do not have a physical switch, you will see UNREACHABLE with a friendly message explaining that this is normal.
- Save device_connector.py.
- Run the connection test by entering this command in your terminal:
python device_connector.py
- You should see output showing the switch hostname, then UNREACHABLE with a message explaining this is expected without physical hardware.
Why does it say UNREACHABLE?
The inventory points to 192.168.1.1 which is a placeholder IP for the clinic's switch. Unless you have a real Cisco switch at that address, the connection times out. This is completely fine.
The generate and validate commands you built in earlier steps work entirely offline. The connector is for when you eventually deploy to real hardware (or a lab environment like GNS3).
Seeing a ModuleNotFoundError?
- If you see ModuleNotFoundError: No module named 'netmiko', your virtual environment may not be active. Run venv\Scripts\activate (Windows) or source venv/bin/activate (macOS/Linux) and try again.
- If the error persists, reinstall the dependency with pip install netmiko==4.7.0.
Why is my device_connector.py not running?
✔️ Awesome, I've got everything!
Great. Double check you have saved device_connector.py and your test showed the UNREACHABLE result.
ⓧ I'd like to double check the full code
Here is the complete device_connector.py file:
"""Connect to the clinic network switch via SSH using Netmiko."""
from netmiko import ConnectHandler
from netmiko.exceptions import (
NetmikoAuthenticationException,
NetmikoTimeoutException,
)
def connect_to_switch(switch_data):
connection_params = {
"device_type": switch_data["device_type"],
"host": switch_data["host"],
"username": switch_data["username"],
"password": switch_data["password"],
"secret": switch_data.get("secret", ""),
"timeout": 10,
}
try:
print(f" Connecting to {switch_data['hostname']} ({switch_data['host']})...")
connection = ConnectHandler(**connection_params)
print(f" Connected successfully to {switch_data['hostname']}")
return connection
except NetmikoAuthenticationException:
print(f" ERROR: Authentication failed for {switch_data['hostname']}")
print(f" Check username/password in inventory file.")
return None
except NetmikoTimeoutException:
print(f" ERROR: Connection timed out for {switch_data['hostname']}")
print(f" Verify the switch is powered on and reachable at {switch_data['host']}")
return None
except Exception as e:
print(f" ERROR: Could not connect to {switch_data['hostname']}: {e}")
return None
def get_running_config(connection):
output = connection.send_command("show running-config")
return output
def push_config(connection, config_commands):
output = connection.send_config_set(config_commands)
return output
def test_connection(switch_data):
result = {
"hostname": switch_data["hostname"],
"host": switch_data["host"],
"reachable": False,
"message": "",
}
connection = connect_to_switch(switch_data)
if connection:
result["reachable"] = True
result["message"] = "Switch is reachable and credentials are valid"
connection.disconnect()
else:
result["message"] = (
"Switch is not reachable. This is expected if you do not have "
"a physical switch connected. The generate and validate commands "
"work without a live switch."
)
return result
if __name__ == "__main__":
from inventory_loader import load_inventory
data = load_inventory("inventory/clinic_network.yaml")
print(f"Testing connection to clinic switch: {data['switch']['hostname']}\n")
result = test_connection(data["switch"])
status = "REACHABLE" if result["reachable"] else "UNREACHABLE"
print(f"\n Result: {status}")
print(f" {result['message']}")
Your connector module is ready. It can reach out to the clinic switch, pull its running configuration, and push new configs when the time comes. Next up, you will build the CLI that ties all four modules together into a single command-line workflow.
Create the Clinic Network CLI
You now have all the building blocks for your clinic network automation tool. The inventory loader reads your YAML, the config generator renders templates, the validator enforces security rules, and the connector handles SSH.
In this step, you will wire everything together into a single command-line interface. This gives you one entry point to generate configs, validate security, test connectivity, and deploy to the switch.
In this step, get ready to:
- Build CLI command functions for generating, validating, testing, and deploying.
- Wire the deploy command to run generate, validate, then push in sequence.
- Run the full workflow and verify the tool works end to end.
Build the CLI command functions
Each CLI command is a Python function that loads the inventory and calls the appropriate module. You will start with three simpler commands before wiring the more complex deploy pipeline.
- In main.py, add the following imports and the first command function:
"""Cardiology Clinic Network Automation Tool - CLI Entry Point."""
import argparse
import sys
from inventory_loader import load_inventory
from config_generator import generate_and_save
from config_validator import validate_config, print_validation_report
from device_connector import test_connection
def cmd_generate(args):
"""Generate switch configuration for the clinic network."""
print("=" * 55)
print(" Cardiology Clinic Network - Config Generator")
print("=" * 55)
print()
data = load_inventory(args.inventory)
generate_and_save(data)
print("\nDone! Configuration saved to 'output/' directory.")
What does this code do?
The imports pull in each module you built in previous steps. Each cmd_ function receives an args object from argparse which carries the --inventory path.
The cmd_generate function loads the clinic inventory and passes it to generate_and_save() which renders your Jinja2 templates and writes the output file.
- Below cmd_generate, add the cmd_validate function:
def cmd_validate(args):
"""Generate and validate the clinic switch configuration."""
print("=" * 55)
print(" Cardiology Clinic Network - Security Validator")
print("=" * 55)
print()
data = load_inventory(args.inventory)
config = generate_and_save(data)
print("\n--- Healthcare Security Validation ---")
result = validate_config(config, data["clinic"]["name"])
print_validation_report(result)
if not result["passed"]:
print("\n ACTION REQUIRED: Fix failing checks before deployment.")
sys.exit(1)
else:
print("\n All security checks passed. Safe to deploy.")
What does this code do?
The validate command generates the config first, then runs every security check from your validator module. If any check fails, sys.exit(1) stops the tool with a non-zero exit code, signalling failure to any automation scripts that call it.
- Below cmd_validate, add the cmd_test_connection function:
def cmd_test_connection(args):
"""Test connectivity to the clinic switch."""
print("=" * 55)
print(" Cardiology Clinic Network - Connection Test")
print("=" * 55)
print()
data = load_inventory(args.inventory)
result = test_connection(data["switch"])
status = "REACHABLE" if result["reachable"] else "UNREACHABLE"
print(f"\n Switch: {result['hostname']} ({result['host']})")
print(f" Status: {status}")
print(f" Detail: {result['message']}")
What does this code do?
This command calls your test_connection() function from device_connector.py. It attempts an SSH connection to the clinic switch and reports whether it is reachable. The switch does not need to be available for the other commands to work.
- Save main.py.
Seeing a syntax error?
Make sure each function starts with def at the top level (no indentation). Check that you have two blank lines between each function definition.
help me fix a syntax error in my main.py CLI file
Wire the deploy pipeline and CLI dispatcher
The deploy command is the most important function. It orchestrates the entire pipeline: generate the config, validate it against security rules, and only then allow deployment. If validation fails, the tool refuses to push anything to the switch.
- Below cmd_test_connection, add the cmd_deploy function:
def cmd_deploy(args):
"""Generate, validate, and deploy configuration to the clinic switch."""
print("=" * 55)
print(" Cardiology Clinic Network - Deploy Configuration")
print("=" * 55)
print()
data = load_inventory(args.inventory)
print("Step 1: Generating configuration...")
config = generate_and_save(data)
print("\nStep 2: Running security validation...")
result = validate_config(config, data["clinic"]["name"])
print_validation_report(result)
if not result["passed"]:
print("\n DEPLOY ABORTED: Configuration failed security validation.")
print(" Fix the issues above before attempting deployment.")
sys.exit(1)
print("\nStep 3: Configuration validated. Ready to deploy.")
print(" To deploy to a live switch, ensure the switch is reachable.")
print(" Run 'test-connection' first to verify connectivity.")
Why does deploy validate before pushing?
In healthcare networks, pushing an insecure configuration could expose patient data. By chaining generate, validate, and deploy in sequence, the tool enforces a safety gate. No configuration reaches the switch unless it passes every security check first.
- Below cmd_deploy, add the main() function that sets up argparse and dispatches commands:
def main():
"""Parse arguments and run the appropriate command."""
parser = argparse.ArgumentParser(
description="Cardiology Clinic Network Automation Tool"
)
parser.add_argument(
"--inventory",
default="inventory/clinic_network.yaml",
help="Path to clinic network inventory YAML file",
)
subparsers = parser.add_subparsers(dest="command", help="Available commands")
subparsers.add_parser("generate", help="Generate switch config for the clinic")
subparsers.add_parser("validate", help="Generate and validate config security")
subparsers.add_parser("test-connection", help="Test switch connectivity")
subparsers.add_parser("deploy", help="Generate, validate, and deploy config")
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(1)
commands = {
"generate": cmd_generate,
"validate": cmd_validate,
"test-connection": cmd_test_connection,
"deploy": cmd_deploy,
}
try:
commands[args.command](args)
except (FileNotFoundError, ValueError) as e:
print(f"Error: {e}")
sys.exit(1)
if __name__ == "__main__":
main()
What does this code do?
- argparse.ArgumentParser creates the CLI with a --inventory flag that defaults to your YAML file path.
- add_subparsers() registers each command name (generate, validate, test-connection, deploy) as a subcommand.
- The commands dictionary maps each subcommand string to its handler function.
- The try/except block catches inventory errors (missing file, invalid format) and prints a clean error message instead of a traceback.
- Save main.py.
Seeing an IndentationError?
Make sure the commands dictionary and the try/except block are indented inside main(). The if __name__ block should be at the top level with no indentation.
help me fix an indentation error in my argparse CLI
✔️ Awesome, I've got everything!
Great. Double check you have saved main.py before moving on.
ⓧ I'd like to double check the full code
"""Cardiology Clinic Network Automation Tool - CLI Entry Point."""
import argparse
import sys
from inventory_loader import load_inventory
from config_generator import generate_and_save
from config_validator import validate_config, print_validation_report
from device_connector import test_connection
def cmd_generate(args):
"""Generate switch configuration for the clinic network."""
print("=" * 55)
print(" Cardiology Clinic Network - Config Generator")
print("=" * 55)
print()
data = load_inventory(args.inventory)
generate_and_save(data)
print("\nDone! Configuration saved to 'output/' directory.")
def cmd_validate(args):
"""Generate and validate the clinic switch configuration."""
print("=" * 55)
print(" Cardiology Clinic Network - Security Validator")
print("=" * 55)
print()
data = load_inventory(args.inventory)
config = generate_and_save(data)
print("\n--- Healthcare Security Validation ---")
result = validate_config(config, data["clinic"]["name"])
print_validation_report(result)
if not result["passed"]:
print("\n ACTION REQUIRED: Fix failing checks before deployment.")
sys.exit(1)
else:
print("\n All security checks passed. Safe to deploy.")
def cmd_test_connection(args):
"""Test connectivity to the clinic switch."""
print("=" * 55)
print(" Cardiology Clinic Network - Connection Test")
print("=" * 55)
print()
data = load_inventory(args.inventory)
result = test_connection(data["switch"])
status = "REACHABLE" if result["reachable"] else "UNREACHABLE"
print(f"\n Switch: {result['hostname']} ({result['host']})")
print(f" Status: {status}")
print(f" Detail: {result['message']}")
def cmd_deploy(args):
"""Generate, validate, and deploy configuration to the clinic switch."""
print("=" * 55)
print(" Cardiology Clinic Network - Deploy Configuration")
print("=" * 55)
print()
data = load_inventory(args.inventory)
print("Step 1: Generating configuration...")
config = generate_and_save(data)
print("\nStep 2: Running security validation...")
result = validate_config(config, data["clinic"]["name"])
print_validation_report(result)
if not result["passed"]:
print("\n DEPLOY ABORTED: Configuration failed security validation.")
print(" Fix the issues above before attempting deployment.")
sys.exit(1)
print("\nStep 3: Configuration validated. Ready to deploy.")
print(" To deploy to a live switch, ensure the switch is reachable.")
print(" Run 'test-connection' first to verify connectivity.")
def main():
"""Parse arguments and run the appropriate command."""
parser = argparse.ArgumentParser(
description="Cardiology Clinic Network Automation Tool"
)
parser.add_argument(
"--inventory",
default="inventory/clinic_network.yaml",
help="Path to clinic network inventory YAML file",
)
subparsers = parser.add_subparsers(dest="command", help="Available commands")
subparsers.add_parser("generate", help="Generate switch config for the clinic")
subparsers.add_parser("validate", help="Generate and validate config security")
subparsers.add_parser("test-connection", help="Test switch connectivity")
subparsers.add_parser("deploy", help="Generate, validate, and deploy config")
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(1)
commands = {
"generate": cmd_generate,
"validate": cmd_validate,
"test-connection": cmd_test_connection,
"deploy": cmd_deploy,
}
try:
commands[args.command](args)
except (FileNotFoundError, ValueError) as e:
print(f"Error: {e}")
sys.exit(1)
if __name__ == "__main__":
main()
Run the full workflow
Your CLI is complete. Time to run it and see the entire pipeline in action.
- Run the generate command to produce the switch configuration:
python main.py generate
- You should see output showing the clinic name, workstation count, and the config file being saved to output/cardio-clinic-sw01.cfg.
- Run the validate command to check the generated config against healthcare security rules:
python main.py validate
- You should see the config generation output followed by a security validation report. Every check should show [+] for PASS, with a final score of all checks passed.
- Run the deploy command to see the full pipeline in sequence:
python main.py deploy
- You should see three steps printed: generating the configuration, running security validation, and confirming the config is ready to deploy.
What about test-connection?
Running python main.py test-connection will show the switch as UNREACHABLE. This is expected. You do not have a physical Cisco switch connected. The tool handles this gracefully and all other commands work without a live device.
Seeing a ModuleNotFoundError?
Make sure your virtual environment is still active (you should see (venv) in your terminal prompt). Also confirm you are running the command from your project directory where main.py and inventory_loader.py are located.
help me fix a ModuleNotFoundError when running my CLI
Your cardiology clinic network automation tool is complete. You can generate VLAN-segmented switch configs, validate them against healthcare security rules, and orchestrate the full deployment pipeline from a single CLI.
Secret mission
Add a Compliance Audit Feature
Your clinic's switch config passes validation today. But what happens when someone manually changes a port setting next week? Build an audit tool that compares your desired config against what is actually running on the switch, flagging any configuration drift that could create security gaps.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- Project folder on your Desktop (all Python modules, templates, inventory, output, and virtual environment).
Keep everything running
No action needed. Choose this if you want to keep developing the clinic network tool or experimenting with new templates and validation rules.
- Your project folder stays on your Desktop with all files intact.
- The virtual environment stays ready. Reactivate it anytime with venv\Scripts\activate (Windows) or source venv/bin/activate (macOS/Linux).
- Python and VS Code remain installed on your machine.
Pause - I'll come back to this later
Deactivate the virtual environment to free up your terminal session. Your files stay on disk for later.
- Deactivate the virtual environment by running:
deactivate
- Close VS Code when you are done.
- Your project folder and all files remain on your Desktop. Reactivate the virtual environment when you return.
Delete - I don't want to use this again
Remove the entire project folder to clean up all files, templates, output configs, and the virtual environment.
- Deactivate the virtual environment first by running:
deactivate
- Navigate to your Desktop and delete the project folder.
On macOS/Linux:
cd ~/Desktop
rm -rf cardio-clinic-network
On Windows (PowerShell):
cd ~\Desktop
Remove-Item -Recurse -Force cardio-clinic-network
Folder name may differ
Replace cardio-clinic-network with whatever you named your project folder in Step 1.
- This removes all Python modules, Jinja2 templates, the YAML inventory, generated configs, and the virtual environment with its installed packages.
Nice Work!
Nice Work!
You just built a network automation tool for a cardiology clinic. Your Python CLI can generate, validate, and deploy switch configurations that enforce VLAN segmentation between administrative and clinical traffic.
You've learned how to:
- Created Jinja2 templates that generate VLAN-segmented switch configurations separating administrative traffic from clinical traffic.
- Implemented healthcare security validation that catches insecure configurations before they reach a live switch.
- Built a Netmiko-powered CLI that orchestrates the full automation pipeline from config generation through validation to SSH deployment.
- Added a compliance audit tool that detects configuration drift between your desired state and what is actually running on the switch.
Ready to quiz yourself?