Starter kit
Five scripts that do real work on real IOS devices
No framework, no agent, no subscription. Plain Python and Netmiko against your own lab. Every script below is short enough to read in one sitting and safe enough to run on a Monday.
The lab pack is free and needs no signup — but if you want the next one when it lands, this is where to put your address.
Before you start
python3 -m venv .venv && source .venv/bin/activate
pip install netmiko pyyaml jinja2
Use SSH keys, not passwords in YAML. The inventory below assumes a key file and no enable secret in the file.
Every script calls enable(), so either use an account already at privilege 15 or add secret: your-enable-secret to each inventory entry. Without one of those, Netmiko fails with Failed to enter enable mode.
# inventory.yml
- device_type: cisco_ios
host: 192.0.2.11
username: automation
use_keys: true
key_file: ~/.ssh/id_ed25519_lab
1. Back up every config, and diff what changed
The one script worth writing first. It gives you a rollback point and a change log for free.
from netmiko import ConnectHandler
from pathlib import Path
from datetime import datetime
import yaml, difflib
devices = yaml.safe_load(Path("inventory.yml").read_text())
out = Path("backups") / datetime.now().strftime("%Y-%m-%d")
out.mkdir(parents=True, exist_ok=True)
for dev in devices:
try:
with ConnectHandler(**dev) as conn:
conn.enable()
cfg = conn.send_command("show running-config", read_timeout=90)
except Exception as exc:
print(f"FAIL {dev['host']}: {type(exc).__name__}")
continue
path = out / f"{dev['host']}.cfg"
if path.exists():
diff = list(difflib.unified_diff(
path.read_text().splitlines(), cfg.splitlines(), lineterm=""))
if diff:
(out / f"{dev['host']}.diff").write_text("\n".join(diff))
path.write_text(cfg)
print(f"{dev['host']}: {len(cfg.splitlines())} lines")
2. Detect drift against a known-good config
Point it at yesterday's backup and exit non-zero when something moved. That exit code is what your cron or CI watches.
The first run has no baseline. Copy one backup in first: mkdir -p golden && cp backups/$(date +%F)/*.cfg golden/. The script also ignores the "Last configuration change" lines, which change every time you save and would otherwise look like drift.
from netmiko import ConnectHandler
from pathlib import Path
import yaml, sys
devices = yaml.safe_load(Path("inventory.yml").read_text())
drifted = 0
VOLATILE = ("Last configuration change", "NVRAM config last updated",
"Current configuration")
for dev in devices:
with ConnectHandler(**dev) as conn:
conn.enable()
live = conn.send_command("show running-config", read_timeout=90)
golden_path = Path("golden") / f"{dev['host']}.cfg"
if not golden_path.exists():
print(f"NO BASELINE {dev['host']} — copy a backup into golden/ first")
drifted += 1
continue
live_cfg = [l for l in live.splitlines() if not l.startswith(VOLATILE)]
gold_cfg = [l for l in golden_path.read_text().splitlines() if not l.startswith(VOLATILE)]
if live_cfg != gold_cfg:
print(f"DRIFT {dev['host']}")
drifted += 1
sys.exit(1 if drifted else 0)
3. Template VLANs instead of typing them
Renders first, prints what it would send, and only touches the device with --apply. This is the pattern to copy for any repeated config.
from jinja2 import Template
from netmiko import ConnectHandler
import yaml, sys
TEMPLATE = """{% for v in vlans %}vlan {{ v.id }}
name {{ v.name }}
{% endfor %}"""
vlans = [{"id": 20, "name": "CAMERAS"}, {"id": 30, "name": "GUEST"}]
devices = yaml.safe_load(open("inventory.yml"))
apply = "--apply" in sys.argv
for dev in devices:
cmds = Template(TEMPLATE).render(vlans=vlans).strip().splitlines()
if not apply:
print(f"--- {dev['host']} (dry run)")
print("\n".join(cmds))
continue
with ConnectHandler(**dev) as conn:
conn.enable()
print(conn.send_config_set(cmds))
print(conn.send_command("write memory")) # without this the change dies at reload
4. Audit the inventory you think you have
Half of automation failures are a stale inventory. This tells you which devices are reachable before anything else runs.
from netmiko import ConnectHandler
from pathlib import Path
import yaml
for dev in yaml.safe_load(Path("inventory.yml").read_text()):
try:
with ConnectHandler(**dev, timeout=8) as conn:
ver = conn.send_command("show version", read_timeout=20)
line = next((l for l in ver.splitlines() if "uptime is" in l), "")
print(f"OK {dev['host']:<15} {line.split()[0] if line else '?'}")
except Exception as e:
print(f"FAIL {dev['host']:<15} {type(e).__name__}")
5. Sweep for the settings that get forgotten
Not a compliance product — a smoke test. Four lines that catch most of the "we'll fix it later" items.
from netmiko import ConnectHandler
from pathlib import Path
import yaml
REQUIRED = ["ntp server", "logging host", "aaa new-model", "transport input ssh"]
for dev in yaml.safe_load(Path("inventory.yml").read_text()):
with ConnectHandler(**dev) as conn:
conn.enable()
cfg = conn.send_command("show running-config", read_timeout=90)
missing = [r for r in REQUIRED if r not in cfg]
status = "clean" if not missing else "MISSING: " + ", ".join(missing)
print(f"{dev['host']:<15} {status}")
The lab these run against
Three routers and two switches is enough: one core, two distribution, two access. Put a management subnet on every device, enable SSHv2, and use a key — then the scripts above work unchanged. GNS3 and EVE-NG both work; use images you are licensed for.
What the course adds
These five scripts are the floor, not the ceiling. The lab course covers idempotent Ansible playbooks, safe bulk rollouts with check mode, parsing with TextFSM, and what to do when a change goes wrong at 2am.
Get early access — $39