Configuration & Template Automation
A Template-Based Configuration Automation Project
What you'll learn
- How structured data, a Jinja2 template, and Reliable Automation's safety pipeline combine into one tool
- Why 'rendered successfully' still never means 'safe to deploy' in this capstone
- How a rendered-configuration validation failure is classified, without inventing a new result state
- How the same reliability logic runs identically with or without a real device
Listen to a guided explanation of this lesson.
Spoken narration isn't supported in this browser.
Extending Reliable Automation's project, one more time
Every capstone in this curriculum has replaced exactly one piece of the previous one. This one is no different: apply_ntp_config()'s hardcoded f"ntp server {REQUIRED_NTP_SERVER}" line becomes a Jinja2-rendered block of VLAN configuration — a genuinely per-device, multi-line, data-driven change instead of one fixed string. Everything else — connect_with_retry(), the dry_run default, independent post-check, structured result classification, logging — carries over unchanged.
- CSV inventorydevices.csv — unchanged since Phase 2.6
- Load & validate devicesis_valid_device() — unchanged
- Connect, with retryconnect_with_retry() — unchanged from Reliable Automation
- Render this device's VLANsrender_vlan_config() — the one new piece
- Validate the rendered configvalidate_rendered_config() — from Lesson 5
- Pre-checkread current VLANs from the device
- Dry-run or applydefault: preview only
- Post-checkindependently verify the required VLANs now exist
- Classify resultthe same 5 states from Reliable Automation
- JSON report + audit logtemplate_deployment_report.json
Safe by default, still
DRY_RUN = True by default, exactly as in Reliable Automation. Rendering configuration and validating it never touches a device — only an explicit DRY_RUN = False, against a lab device you fully control, does that.
The VLAN data and the template
REQUIRED_VLANS = {
"SW1": [{"id": 10, "name": "USERS"}, {"id": 20, "name": "VOICE"}],
"SW2": [{"id": 10, "name": "USERS"}],
"SW3": [{"id": 10, "name": "USERS"}, {"id": 30, "name": "MGMT"}],
"SW4": [{"id": 10, "name": "USERS"}],
"SW5": [{"id": 10, "name": "USERS"}],
}Each device's required VLANs, by hostname — the same list-of-dicts shape from Lesson 3. devices.csv itself stays exactly hostname/ip/platform, unchanged; this is a separate piece of structured data, the same way REQUIRED_NTP_SERVER was a separate constant in Reliable Automation.
{% for vlan in vlans %}
vlan {{ vlan.id }}
name {{ vlan.name }}
{% endfor %}The exact template from Lesson 5 — one file, reused for every device in this capstone.
The complete script
import csv
import json
import logging
import time
from jinja2 import Environment, FileSystemLoader
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
DRY_RUN = True # default: never changes a real device unless explicitly set to False
REQUIRED_VLANS = {
"SW1": [{"id": 10, "name": "USERS"}, {"id": 20, "name": "VOICE"}],
"SW2": [{"id": 10, "name": "USERS"}],
"SW3": [{"id": 10, "name": "USERS"}, {"id": 30, "name": "MGMT"}],
"SW4": [{"id": 10, "name": "USERS"}],
"SW5": [{"id": 10, "name": "USERS"}],
}
def is_valid_device(device):
"""A device record needs at least a hostname and an IP to be usable."""
return bool(device.get("hostname")) and bool(device.get("ip"))
class MockConnection:
"""Stands in for a real Netmiko connection -- see get_connection() below."""
def __init__(self, existing_vlan_ids):
self.vlan_ids = set(existing_vlan_ids)
def send_command(self, cmd):
return "\n".join(f"{vid} VLAN{vid}" for vid in sorted(self.vlan_ids))
def send_config_set(self, lines):
for line in lines:
stripped = line.strip()
if stripped.startswith("vlan "):
self.vlan_ids.add(int(stripped.split(" ")[1]))
return "ok"
# Option B, no device needed: a deterministic scenario per hostname -- the exact
# same five outcomes as Reliable Automation's capstone, now driven by rendered
# VLAN config instead of a hardcoded NTP line.
_MOCK_SCENARIOS = {
"SW1": {"fails_before_success": 0, "auth_fails": False, "existing_vlan_ids": [10, 20]},
"SW2": {"fails_before_success": 0, "auth_fails": False, "existing_vlan_ids": []},
"SW3": {"fails_before_success": 2, "auth_fails": False, "existing_vlan_ids": [10]},
"SW4": {"fails_before_success": 99, "auth_fails": False, "existing_vlan_ids": []},
"SW5": {"fails_before_success": 0, "auth_fails": True, "existing_vlan_ids": []},
}
_attempt_counts = {}
_MOCK_CONNECTIONS = {h: MockConnection(s["existing_vlan_ids"]) for h, s in _MOCK_SCENARIOS.items()}
def get_connection(device):
hostname = device["hostname"]
scenario = _MOCK_SCENARIOS[hostname]
if scenario["auth_fails"]:
raise PermissionError(f"{hostname} rejected the credentials")
_attempt_counts[hostname] = _attempt_counts.get(hostname, 0) + 1
if _attempt_counts[hostname] <= scenario["fails_before_success"]:
raise TimeoutError(f"{hostname} did not respond")
return _MOCK_CONNECTIONS[hostname]
def connect_with_retry(device, max_attempts=3, delay=2):
"""The same retry policy from Reliable Automation, unchanged."""
for attempt in range(1, max_attempts + 1):
try:
return get_connection(device), attempt
except TimeoutError:
if attempt == max_attempts:
raise
time.sleep(delay)
except PermissionError:
raise
def render_vlan_config(hostname, template):
"""Renders this device's required VLANs through the shared Jinja2 template."""
required_vlans = REQUIRED_VLANS[hostname]
return template.render(vlans=required_vlans), required_vlans
def validate_rendered_config(rendered_text, required_vlans):
"""The same check from Lesson 5: every required 'vlan <id>' line must actually be present."""
missing = [v["id"] for v in required_vlans if f"vlan {v['id']}" not in rendered_text]
return {"valid": len(missing) == 0, "missing_vlan_ids": missing}
def missing_vlans(show_vlan_output, required_vlans):
"""Generalizes Netmiko Fundamentals' is_vlan_present() from one VLAN to a required list."""
present_ids = set()
for line in show_vlan_output.splitlines():
first_word = line.strip().split(" ")[0] if line.strip() else ""
if first_word.isdigit():
present_ids.add(int(first_word))
return [v for v in required_vlans if v["id"] not in present_ids]
def apply_vlan_config(connection, hostname, template, dry_run=True):
"""Render, validate the render, pre-check, and only then decide whether to change anything."""
rendered, required_vlans = render_vlan_config(hostname, template)
validation = validate_rendered_config(rendered, required_vlans)
if not validation["valid"]:
return {"changed": False, "verified": False, "dry_run": dry_run, "render_valid": False}
pre_check = connection.send_command("show vlan brief")
to_add = missing_vlans(pre_check, required_vlans)
if not to_add:
return {"changed": False, "verified": True, "dry_run": dry_run, "render_valid": True}
if dry_run:
return {"changed": False, "verified": None, "dry_run": True, "render_valid": True}
connection.send_config_set(rendered.strip().splitlines())
post_check = connection.send_command("show vlan brief")
still_missing = missing_vlans(post_check, required_vlans)
return {"changed": True, "verified": len(still_missing) == 0, "dry_run": False, "render_valid": True}
def run_device(device, template, dry_run=True):
hostname = device["hostname"]
try:
connection, attempts = connect_with_retry(device)
except TimeoutError:
logging.warning(f"{hostname}: unreachable after 3 attempts")
return {"hostname": hostname, "ip": device["ip"], "status": "unreachable", "attempts": 3, "retried": True}
except PermissionError:
logging.warning(f"{hostname}: authentication failed")
return {"hostname": hostname, "ip": device["ip"], "status": "auth-failed", "attempts": 1, "retried": False}
outcome = apply_vlan_config(connection, hostname, template, dry_run=dry_run)
retried = attempts > 1
if not outcome["render_valid"]:
status = "change-not-verified"
elif outcome["dry_run"] and not outcome["changed"] and outcome["verified"] is None:
status = "dry-run-preview"
elif outcome["changed"]:
status = "changed" if outcome["verified"] else "change-not-verified"
else:
status = "already-compliant"
logging.info(f"{hostname}: {status} (attempts={attempts})")
return {"hostname": hostname, "ip": device["ip"], "status": status, "attempts": attempts, "retried": retried}
env = Environment(loader=FileSystemLoader("templates"), trim_blocks=True, lstrip_blocks=True)
vlan_template = env.get_template("vlan_config.j2")
with open("devices.csv") as f:
devices = list(csv.DictReader(f))
valid_devices = []
for device in devices:
if is_valid_device(device):
valid_devices.append(device)
else:
print(f"Skipping invalid inventory record: {device}")
results = [run_device(device, vlan_template, dry_run=DRY_RUN) for device in valid_devices]
with open("template_deployment_report.json", "w") as f:
json.dump(results, f, indent=4)
by_status = {}
for r in results:
by_status[r["status"]] = by_status.get(r["status"], 0) + 1
print("\n--- Summary ---")
print(f"Checked: {len(results)} devices")
for status, count in by_status.items():
print(f"{status}: {count}")
print("Report written to template_deployment_report.json")Save alongside devices.csv and templates/vlan_config.j2, then run 'python template_deployment.py' — genuinely complete and runnable using the built-in simulation (Option B). get_connection() is the one piece Option A replaces, shown next.
Why there's still no 6th result state
A render validation failure is real — but this capstone doesn't invent a new status bucket for it. apply_vlan_config() returns render_valid: False when validation fails, and run_device() routes that into the existing change-not-verified state, not a new one:
if not outcome["render_valid"]:
status = "change-not-verified"
elif outcome["dry_run"] and not outcome["changed"] and outcome["verified"] is None:
status = "dry-run-preview"
elif outcome["changed"]:
status = "changed" if outcome["verified"] else "change-not-verified"
else:
status = "already-compliant"Checked first, deliberately: an invalid render must never fall through to 'already-compliant' by accident. 'change-not-verified' already means exactly the right thing here — a change this script can't currently vouch for.
Reusing the model on purpose
Reliable Automation's five states already cover "a change we can't verify actually happened correctly." A rendered-configuration validation failure is exactly that kind of unverifiable change — so it reuses change-not-verified rather than adding a sixth state that would mean almost the same thing.
To see this actually work, Lesson 5's deliberately broken template (missing the vlan {{ vlan.id }} line) is run through apply_vlan_config() directly:
outcome = apply_vlan_config(mock_connection, "SW1", broken_template, dry_run=True)
print(outcome)Using SW1 — which, with a correct template, is already-compliant — against the broken template from Lesson 5 instead.
{'changed': False, 'verified': False, 'dry_run': True, 'render_valid': False}render_valid is False, which run_device() checks first — so this classifies as change-not-verified, not already-compliant, even though SW1's real VLANs would otherwise already satisfy the (correct) template's requirements.
The sample inventory
hostname,ip,platform
SW1,10.10.10.1,cisco_ios
SW2,10.10.10.2,cisco_ios
SW3,10.10.10.3,cisco_ios
SW4,10.10.10.4,cisco_ios
SW5,10.10.10.5,cisco_iosUnchanged from Reliable Automation — the same five devices, exercising the same five outcomes, now via rendered VLAN config instead of a hardcoded NTP line.
Two ways to run it
- Real or virtual deviceown hardware, CML/GNS3/EVE-NG, or a currently available Cisco-hosted lab
- get_connection() uses NetmikoConnectHandler(), same as Reliable Automation
- Real template_deployment_report.json
- Nothing beyond Python and Jinja2 needed
- get_connection() uses a deterministic mocksame retry/timeout/auth-failure behavior, simulated
- The same shape of template_deployment_report.json
from netmiko import ConnectHandler, NetmikoTimeoutException, NetmikoAuthenticationException
def get_connection(device):
"""Replace the mock get_connection() above with this to use a real device."""
try:
return ConnectHandler(
device_type=device["platform"],
host=device["ip"],
username=USERNAME, # supplied via input()/getpass(), never hardcoded
password=PASSWORD,
conn_timeout=10,
)
except NetmikoTimeoutException:
raise TimeoutError(f"{device['hostname']} did not respond")
except NetmikoAuthenticationException:
raise PermissionError(f"{device['hostname']} rejected the credentials")Identical to Reliable Automation's Option A — same function name, same two exceptions raised. Every function after connect_with_retry() doesn't need to know or care which version of get_connection() is running underneath it.
Running it: expected terminal output
2026-08-25 22:39:25 INFO SW1: already-compliant (attempts=1)
2026-08-25 22:39:25 INFO SW2: dry-run-preview (attempts=1)
2026-08-25 22:39:29 INFO SW3: dry-run-preview (attempts=3)
2026-08-25 22:39:33 WARNING SW4: unreachable after 3 attempts
2026-08-25 22:39:33 WARNING SW5: authentication failed
--- Summary ---
Checked: 5 devices
already-compliant: 1
dry-run-preview: 2
unreachable: 1
auth-failed: 1
Report written to template_deployment_report.jsonSW1 already has both required VLANs. SW2 and SW3 each need a change — previewed, not applied. SW3 needed two retries first. SW4 exhausted its retries. SW5's credentials were rejected immediately.
2026-08-25 22:39:41 INFO SW1: already-compliant (attempts=1)
2026-08-25 22:39:41 INFO SW2: changed (attempts=1)
2026-08-25 22:39:45 INFO SW3: changed (attempts=3)
2026-08-25 22:39:49 WARNING SW4: unreachable after 3 attempts
2026-08-25 22:39:49 WARNING SW5: authentication failed
--- Summary ---
Checked: 5 devices
already-compliant: 1
changed: 2
unreachable: 1
auth-failed: 1
Report written to template_deployment_report.jsonSame five devices, DRY_RUN=False: SW2 and SW3's rendered VLAN configuration is now actually applied via send_config_set() and independently verified with a post-check re-read — against the built-in simulation, still no real device touched.
If you set DRY_RUN = False for real
Only against a disposable lab device you fully control — never production. Rendering and validating configuration is always safe; applying it is the one step that actually changes a device, and it stays opt-in by design.
What this capstone actually proved
"Rendered successfully" was never treated as "safe to deploy" anywhere in this pipeline — every rendered block passed through validate_rendered_config() before a connection was even opened, exactly as Lesson 5 taught. And every mechanism from Reliable Automation — retry, dry-run, independent post-check, structured results — runs completely unchanged underneath a configuration source that's now genuinely templated, data-driven, and reusable across any number of devices, VLANs, or naming schemes, without editing the template itself.
Key takeaways
- This capstone reuses is_valid_device(), devices.csv, connect_with_retry(), the dry_run default, and the entire post-check/classification pipeline from Reliable Automation, unchanged.
- The one new piece is render_vlan_config() + validate_rendered_config() — structured VLAN data, rendered through a real .j2 file, checked before any device is even contacted.
- A rendered-configuration validation failure reuses the existing change-not-verified state — deliberately, rather than adding a sixth state that would mean nearly the same thing.
- Only get_connection() differs between the real (Netmiko) and simulated path — every templating and reliability behavior this module built runs identically either way.
- 'Rendered successfully' is never treated as 'safe to deploy' anywhere in this pipeline — validation runs before every device's pre-check, unconditionally.
Done with this lesson?
Next lesson
Why Ansible? Declarative Automation