Reliable Automation
A Reliable Network Automation Workflow
What you'll learn
- How every lesson in this module combines into one complete, reliable tool
- Why the capstone defaults to dry_run=True, and what changing that actually means
- How the same reliability logic runs identically with or without a real device
- How to trace a 5-device inventory through to the exact terminal output and JSON report
- That this module closed every gap named in Lesson 1, on the same project throughout
Listen to a guided explanation of this lesson.
Spoken narration isn't supported in this browser.
Every gap from Lesson 1, closed
- CSV inventorydevices.csv — unchanged since Phase 2.6
- Load & validate devicesis_valid_device() — unchanged
- Connect, with retrybounded, selective — Lesson 2
- Pre-checkread current NTP config
- Decidechange needed? — idempotency
- Dry-run or applydefault: preview only — Lesson 4
- Post-checkindependently verify — Lesson 3
- Classify result5 states, not 3 — Lesson 5
- JSON report + audit logreliability_report.json
Safe by default
This script's default is DRY_RUN = True. Running it — against a real device or the built-in simulation — never changes anything unless that's deliberately switched to False, and even then, only against a lab device you fully control. Nothing about this capstone requires making a real change to be complete.
The complete script
import csv
import json
import logging
import time
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
REQUIRED_NTP_SERVER = "10.0.0.5"
DRY_RUN = True # default: never changes a real device unless explicitly set to False
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 'apply_ntp_config' below."""
def __init__(self, already_has_ntp):
self.configured = already_has_ntp
def send_command(self, cmd):
return f"ntp server {REQUIRED_NTP_SERVER}" if self.configured else ""
def send_config_set(self, lines):
self.configured = True
return "ok"
# Option B, no device needed: a deterministic scenario per hostname.
# Option A swaps get_connection() for the real Netmiko version shown below.
_MOCK_SCENARIOS = {
"SW1": {"fails_before_success": 0, "auth_fails": False, "already_has_ntp": True},
"SW2": {"fails_before_success": 0, "auth_fails": False, "already_has_ntp": False},
"SW3": {"fails_before_success": 2, "auth_fails": False, "already_has_ntp": False},
"SW4": {"fails_before_success": 99, "auth_fails": False, "already_has_ntp": False},
"SW5": {"fails_before_success": 0, "auth_fails": True, "already_has_ntp": False},
}
_attempt_counts = {}
_MOCK_CONNECTIONS = {h: MockConnection(s["already_has_ntp"]) 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):
"""Retries a timeout up to max_attempts times; never retries an authentication failure."""
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 apply_ntp_config(connection, required_ntp_server, dry_run=True):
"""Pre-check, change only if needed, and independently post-check to verify."""
pre_check = connection.send_command("show run | include ntp server")
already_configured = required_ntp_server in pre_check
if already_configured:
return {"changed": False, "verified": True, "dry_run": dry_run}
if dry_run:
return {"changed": False, "verified": None, "dry_run": True}
connection.send_config_set([f"ntp server {required_ntp_server}"])
post_check = connection.send_command("show run | include ntp server")
verified = required_ntp_server in post_check
return {"changed": True, "verified": verified, "dry_run": False}
def run_device(device, 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_ntp_config(connection, REQUIRED_NTP_SERVER, dry_run=dry_run)
retried = attempts > 1
if 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}
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, dry_run=DRY_RUN) for device in valid_devices]
with open("reliability_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 reliability_report.json")Save this as reliable_health_check.py alongside devices.csv and run 'python reliable_health_check.py' — genuinely complete and runnable as shown, using the built-in simulation (Option B). get_connection() is the one piece Option A replaces, shown next.
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_iosFive devices this time, deliberately, so the report can show every outcome this module built: already correct, needs a change, needs a retry, unreachable, and rejected credentials.
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 Netmiko Fundamentals
- Real reliability_report.json
- Nothing beyond Python needed
- get_connection() uses a deterministic mocksame retry/timeout/auth-failure behavior, simulated
- The same shape of reliability_report.json
Both options run the exact same connect_with_retry, apply_ntp_config, and run_device — only get_connection(), the single innermost function that actually reaches out to a device, differs. This is deliberate, and it's why Option B is genuinely useful: every retry, every dry-run decision, every classification in this lesson runs for real either way.
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")Same function name, same two exceptions raised (TimeoutError, PermissionError) — connect_with_retry() and everything after it 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:23:41 INFO SW1: already-compliant (attempts=1)
2026-08-25 22:23:41 INFO SW2: dry-run-preview (attempts=1)
2026-08-25 22:23:41 INFO SW3: dry-run-preview (attempts=3)
2026-08-25 22:23:41 WARNING SW4: unreachable after 3 attempts
2026-08-25 22:23:41 WARNING SW5: authentication failed
--- Summary ---
Checked: 5 devices
already-compliant: 1
dry-run-preview: 2
unreachable: 1
auth-failed: 1
Report written to reliability_report.jsonSW1 was already correct. SW2 would need a change — previewed, not applied. SW3 needed two retries before it even got that far, then would also need a change. SW4 exhausted its retries. SW5's credentials were rejected on the first try, no retry attempted.
The generated report
[
{
"hostname": "SW1",
"ip": "10.10.10.1",
"status": "already-compliant",
"attempts": 1,
"retried": false
},
{
"hostname": "SW2",
"ip": "10.10.10.2",
"status": "dry-run-preview",
"attempts": 1,
"retried": false
},
{
"hostname": "SW3",
"ip": "10.10.10.3",
"status": "dry-run-preview",
"attempts": 3,
"retried": true
},
{
"hostname": "SW4",
"ip": "10.10.10.4",
"status": "unreachable",
"attempts": 3,
"retried": true
},
{
"hostname": "SW5",
"ip": "10.10.10.5",
"status": "auth-failed",
"attempts": 1,
"retried": false
}
]Every device has exactly one entry, with enough detail to answer 'what happened, and how many tries did it take?' — not just a bare status.
If you set DRY_RUN = False
Only do this against a disposable lab device you fully control — never production, and never without reviewing exactly what the change will do first. Everything in this lesson was designed so the default stays safe; changing that default is a deliberate choice you make, not an accident that can happen by running the script normally.
What this module actually closed
Compare this against Lesson 1's list: no retry policy — closed. No dry-run — closed. No independent post-change verification — closed. A coarse 3-state result model — closed. The tool is recognizably the same one from "Your First Network Automation" and "Netmiko Fundamentals" — inventory, connection, validation, reporting — now with the reliability properties Lesson 1 named actually built in.
Key takeaways
- This capstone reuses is_valid_device() and the CSV inventory format unchanged, and combines every function built across this module's five prior lessons.
- DRY_RUN = True by default — completing this capstone never requires making a real change to a device.
- Only get_connection() differs between the real (Netmiko) and simulated path — every reliability behavior this module teaches runs identically either way.
- The report now carries five possible outcomes with attempt counts, not three bare buckets — an honest, audit-friendly record of what actually happened.
- Every gap named in Lesson 1 — retry, dry-run, verification, result detail — is closed by the end of this lesson, on the same project throughout.