Config Management · python · 25 copies
Running vs Startup Config Diff
Finds configuration that is running but was never saved — changes that silently disappear on the next reload. Also compares against the newest file from config-backup to detect drift since that backup. Strips timestamps and other volatile lines so the diff shows real change only. Correctly reports that IOS-XR, Junos and FortiOS have no unsaved state rather than producing a meaningless diff. Returns a non-zero exit code so it works as a cron or CI check.
- Arista EOS
- FortiOS
- Cisco IOS
- Cisco IOS-XE
- Cisco IOS-XR
- Junos
- Cisco NX-OS
- PAN-OS
pip install "netmiko>=4.3" "PyYAML>=6.0"#!/usr/bin/env python3
"""
Running vs Startup Config Diff
READ THE DOCUMENTATION BEFORE YOU RUN THIS:
https://permitany.com/scripts/config-diff
It covers what the script does, whether it is safe to run, the
inventory file and credentials it needs, how to read the output,
and what to do when something fails.
pip install "netmiko>=4.3" "PyYAML>=6.0"
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
try:
import yaml
except ImportError:
yaml = None
from netmiko import ConnectHandler
from netmiko.exceptions import NetmikoAuthenticationException, NetmikoTimeoutException
import difflib
from datetime import datetime, timezone
# ---------------------------------------------------------------------------
# Inventory loading and connection
# ---------------------------------------------------------------------------
NETMIKO_DRIVER = {
"ios": "cisco_ios",
"iosxe": "cisco_ios",
"iosxr": "cisco_xr",
"nxos": "cisco_nxos",
"junos": "juniper_junos",
"eos": "arista_eos",
"panos": "paloalto_panos",
"fortios": "fortinet",
}
def load_inventory(path: str) -> list[dict]:
"""
Read devices.yaml (or .json) and return a list of device dicts.
Applies the `defaults:` block to every device, validates the `os` field,
and fails with a readable message rather than a stack trace.
"""
p = Path(path)
if not p.exists():
raise SystemExit(
f"Inventory file not found: {path}\n"
f"Create it with:\n\n"
f" devices:\n"
f" - host: 192.168.1.1\n"
f" os: ios\n"
)
raw = p.read_text(encoding="utf-8")
try:
if p.suffix.lower() in (".yaml", ".yml"):
if yaml is None:
raise SystemExit("PyYAML is not installed. Run: pip install PyYAML")
data = yaml.safe_load(raw)
else:
data = json.loads(raw)
except SystemExit:
raise
except Exception as exc:
raise SystemExit(f"Could not parse {path}: {exc}\n"
f"Check for tabs (use spaces) and missing spaces after colons.")
if isinstance(data, dict):
defaults = data.get("defaults", {}) or {}
devices = data.get("devices", []) or []
else:
defaults, devices = {}, data
if not devices:
raise SystemExit(f"No devices found in {path}. The file needs a `devices:` list.")
out = []
for i, d in enumerate(devices, 1):
if not isinstance(d, dict):
raise SystemExit(f"Device #{i} in {path} is not a mapping. "
f"Each entry needs at least `host:` and `os:`.")
merged = {**defaults, **d}
if "host" not in merged:
raise SystemExit(f"Device #{i} in {path} has no `host:` field.")
os_key = merged.get("os")
if not os_key:
raise SystemExit(f"Device '{merged['host']}' has no `os:` field. "
f"Valid values: {', '.join(sorted(NETMIKO_DRIVER))}")
if os_key not in NETMIKO_DRIVER:
raise SystemExit(
f"Device '{merged['host']}' has os '{os_key}', which is not supported.\n"
f"Valid values: {', '.join(sorted(NETMIKO_DRIVER))}\n"
f"Note: 'cisco' is a vendor, not an OS - use ios, iosxe, iosxr, or nxos."
)
merged.setdefault("vrfs", [])
out.append(merged)
return out
def credentials() -> tuple[str, str, str]:
"""Read credentials from the environment and fail clearly if missing."""
user = os.environ.get("NET_USER", "")
pwd = os.environ.get("NET_PASS", "")
enable = os.environ.get("NET_ENABLE", "")
if not user or not pwd:
raise SystemExit(
"Set NET_USER and NET_PASS in your environment before running.\n\n"
" Linux/macOS: export NET_USER='admin'\n"
" export NET_PASS='your-password'\n\n"
" PowerShell: $env:NET_USER = 'admin'\n"
" $env:NET_PASS = 'your-password'\n"
)
return user, pwd, enable
def connect(dev: dict):
"""Open an SSH session to one device using netmiko."""
user, pwd, enable = credentials()
params = {
"device_type": NETMIKO_DRIVER[dev["os"]],
"host": dev["host"],
"username": dev.get("username") or user,
"password": dev.get("password") or pwd,
"port": dev.get("port", 22),
"conn_timeout": 20,
"fast_cli": False,
}
secret = dev.get("secret") or enable
if secret:
params["secret"] = secret
conn = ConnectHandler(**params)
if secret and dev["os"] in ("ios", "iosxe", "nxos"):
conn.enable()
return conn
# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------
# Running config, per OS.
RUNNING = {
"ios": "show running-config",
"iosxe": "show running-config",
"iosxr": "show running-config",
"nxos": "show running-config",
"junos": "show configuration | display set",
"eos": "show running-config",
"panos": "show config running",
"fortios": "show full-configuration",
}
# Saved/startup config, per OS. Platforms with a commit model have no separate
# startup file - what is committed IS what boots - so they are compared a
# different way (see STARTUP_NOTE).
STARTUP = {
"ios": "show startup-config",
"iosxe": "show startup-config",
"nxos": "show startup-config",
"eos": "show startup-config",
"iosxr": None,
"junos": None,
"panos": "show config saved",
"fortios": None,
}
STARTUP_NOTE = {
"iosxr": "IOS-XR commits to persistent storage - there is no unsaved state",
"junos": "Junos commits to persistent storage - there is no unsaved state",
"fortios": "FortiOS saves immediately - there is no unsaved state",
}
# Lines that differ on every read even when nothing changed.
VOLATILE = (
"Building configuration",
"Current configuration",
"! Last configuration change",
"! NVRAM config last updated",
"ntp clock-period",
"!Time:",
"## Last commit:",
"Time: ",
)
def normalise(text: str) -> list[str]:
return [
ln.rstrip() for ln in text.splitlines()
if ln.strip() and not any(ln.strip().startswith(v) for v in VOLATILE)
]
def newest_backup(backup_dir: str, host: str) -> Path | None:
d = Path(backup_dir) / host
if not d.exists():
return None
files = sorted(d.glob("*.cfg"), key=lambda p: p.stat().st_mtime, reverse=True)
return files[0] if files else None
# ---------------------------------------------------------------------------
# Per-device comparison
# ---------------------------------------------------------------------------
def diff_device(dev: dict, args) -> dict:
host = dev["host"]
os_key = dev["os"]
conn = None
try:
conn = connect(dev)
running_raw = conn.send_command(RUNNING[os_key], read_timeout=args.read_timeout)
if not running_raw.strip():
return {"host": host, "os": os_key, "status": "error",
"error": "running-config came back empty - check account privilege"}
running = normalise(running_raw)
if args.mode == "backup":
prev = newest_backup(args.backup_dir, host)
if not prev:
return {"host": host, "os": os_key, "status": "skip",
"note": f"no previous backup under {args.backup_dir}/{host}/"}
baseline = normalise(prev.read_text(encoding="utf-8"))
from_label, to_label = f"backup ({prev.name})", "running"
else:
startup_cmd = STARTUP.get(os_key)
if not startup_cmd:
return {"host": host, "os": os_key, "status": "skip",
"note": STARTUP_NOTE.get(os_key, f"no startup config on {os_key}")}
startup_raw = conn.send_command(startup_cmd, read_timeout=args.read_timeout)
baseline = normalise(startup_raw)
from_label, to_label = "startup", "running"
diff = list(difflib.unified_diff(
baseline, running, fromfile=from_label, tofile=to_label,
lineterm="", n=args.context,
))
changed = [d for d in diff
if d.startswith(("+", "-")) and not d.startswith(("---", "+++"))]
added = sum(1 for d in changed if d.startswith("+"))
removed = sum(1 for d in changed if d.startswith("-"))
if changed and args.save_diffs:
d = Path(args.diff_dir)
d.mkdir(parents=True, exist_ok=True)
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
(d / f"{host}_{ts}.diff").write_text("\n".join(diff) + "\n", encoding="utf-8")
return {"host": host, "os": os_key, "status": "ok",
"added": added, "removed": removed,
"from": from_label, "to": to_label,
"diff": diff if changed else []}
except NetmikoAuthenticationException as exc:
return {"host": host, "os": os_key, "status": "error",
"error": f"authentication failed - check NET_USER/NET_PASS ({exc})"}
except NetmikoTimeoutException as exc:
return {"host": host, "os": os_key, "status": "error",
"error": f"unreachable or SSH timed out ({exc})"}
except Exception as exc: # noqa: BLE001 - one device must not abort the run
return {"host": host, "os": os_key, "status": "error",
"error": f"{type(exc).__name__}: {exc}"}
finally:
if conn:
try:
conn.disconnect()
except Exception: # noqa: BLE001
pass
def main() -> int:
ap = argparse.ArgumentParser(
description="Find configuration that is running but not saved, or that "
"drifted since the last backup.",
epilog="Credentials come from NET_USER and NET_PASS. See the docstring at "
"the top of this file for the full inventory format.",
)
ap.add_argument("--inventory", required=True,
help="Path to devices.yaml listing which devices to compare")
ap.add_argument("--mode", choices=["startup", "backup"], default="startup",
help="startup: running vs startup-config, finds unsaved changes. "
"backup: running vs the newest file from config-backup, "
"finds drift since that backup. Default: startup")
ap.add_argument("--backup-dir", default="./backups",
help="Where config-backup wrote its files. Used by --mode backup")
ap.add_argument("--limit", help="Only compare hosts whose name contains this text")
ap.add_argument("--show-diff", action="store_true",
help="Print the actual changed lines, not just the counts")
ap.add_argument("--context", type=int, default=2,
help="Lines of unchanged context around each change. Default: 2")
ap.add_argument("--save-diffs", action="store_true",
help="Write each diff to a file under --diff-dir")
ap.add_argument("--diff-dir", default="./diffs")
ap.add_argument("--read-timeout", type=int, default=180,
help="Seconds to wait per config download. Default: 180")
ap.add_argument("--workers", type=int, default=10,
help="How many devices to contact at once. Default: 10")
ap.add_argument("--json-out", help="Write machine-readable results here")
args = ap.parse_args()
devices = load_inventory(args.inventory)
if args.limit:
devices = [d for d in devices if args.limit in d["host"]]
if not devices:
raise SystemExit(f"No device name contains '{args.limit}'.")
credentials() # fail fast before opening any connection
what = ("running vs startup (unsaved changes)" if args.mode == "startup"
else f"running vs newest backup in {args.backup_dir}/")
print(f"Comparing {what} on {len(devices)} device(s)...")
print("=" * 74)
results = []
with ThreadPoolExecutor(max_workers=args.workers) as pool:
futures = {pool.submit(diff_device, d, args): d["host"] for d in devices}
for fut in as_completed(futures):
host = futures[fut]
try:
results.append(fut.result())
except Exception as exc: # noqa: BLE001
results.append({"host": host, "os": "?", "status": "error",
"error": f"worker crashed: {exc}"})
drifted = 0
for r in sorted(results, key=lambda x: x["host"]):
label = f"{r['host']} ({r['os']})"
if r["status"] == "error":
print(f"[ERROR] {label:<28} {r['error']}")
elif r["status"] == "skip":
print(f"[SKIP] {label:<28} {r['note']}")
elif not r["diff"]:
print(f"[SAME] {label:<28} no difference")
else:
drifted += 1
print(f"[DIFF] {label:<28} +{r['added']} -{r['removed']} "
f"({r['from']} -> {r['to']})")
if args.show_diff:
for line in r["diff"]:
print(f" {line}")
print("=" * 74)
same = sum(1 for r in results if r["status"] == "ok" and not r["diff"])
err = sum(1 for r in results if r["status"] == "error")
skip = sum(1 for r in results if r["status"] == "skip")
print(f"{same} identical, {drifted} with differences, {skip} skipped, "
f"{err} unreachable (of {len(devices)} device(s)).")
if drifted and args.mode == "startup":
print("\nDevices with differences have configuration that will be LOST on reload.")
print("Save it with 'write memory' (IOS/EOS) or 'copy run start' (NX-OS).")
if args.json_out:
slim = [{k: v for k, v in r.items() if k != "diff"} for r in results]
Path(args.json_out).write_text(json.dumps(slim, indent=2), encoding="utf-8")
return 1 if (drifted or err) else 0
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
print("\nInterrupted.", file=sys.stderr)
sys.exit(130)
--inventorytextrequiredPath to your devices.yaml file. Lists which devices to compare, each with a host and an os field. See the output example below for the full format and every valid os value.
--modetextstartup: compares running vs startup-config to find unsaved changes. backup: compares running vs the newest file written by config-backup, to find drift since that snapshot. Default: startup
--backup-dirtextWhere config-backup wrote its files. Only used by --mode backup. Default: ./backups
--limittextOnly compare hosts whose name contains this text.
--show-diffboolPrint the actual changed lines in unified diff format, not just the added/removed counts.
--contextnumberLines of unchanged context shown around each change. Default: 2
--save-diffsboolWrite each device's full diff to a timestamped file, useful as change-record evidence.
--diff-dirtextWhere --save-diffs writes. Default: ./diffs
--read-timeoutnumberSeconds to wait per config download. Raise for very large configs. Default: 180
--workersnumberHow many devices to contact at once. Default: 10
--json-outtextWrite machine-readable results to this file.
What This Solves
Somebody fixed something at 2am, it worked, and they never saved it. The device runs fine for eight months. Then it reloads for an unrelated reason and the fix is gone - and nobody connects the outage to a change made last year. Unsaved configuration is one of the quietest problems in networking, because everything works right up until it does not. This compares, across a fleet at once: running vs startup what is live but not saved. The --mode startup default. This is the one that bites. running vs your backup what has changed since the last run of config-backup. The --mode backup option - drift detection, including changes made by people who do not tell you. It strips timestamps and other volatile lines first, so the diff is real change and not noise, and it correctly reports that IOS-XR, Junos and FortiOS have no unsaved state at all rather than inventing a difference. Run it nightly. A device that reports unsaved changes is a device with a countdown on it.
Is It Safe to Run
Yes. Completely read-only on the device. One or two show commands and nothing else - it never enters configuration mode, and it never saves anything, which is worth being explicit about: **this script tells you about unsaved configuration, it does not save it for you.** That decision is yours, because "running" is not automatically the version you want to keep. The commands it sends: running config ios, iosxe, iosxr, nxos, eos show running-config junos show configuration | display set panos show config running fortios show full-configuration startup config (--mode startup) ios, iosxe, nxos, eos show startup-config panos show config saved iosxr, junos, fortios not applicable - see below Reading the running config generally needs privileged exec. On your own machine it writes only with --save-diffs, into --diff-dir (default ./diffs).
The Platforms with No Unsaved State
IOS-XR, Junos and FortiOS are reported [SKIP] in --mode startup, with the reason, and that is the correct answer rather than a gap: iosxr commits to persistent storage - there is no unsaved state junos commits to persistent storage - there is no unsaved state fortios saves immediately - there is no unsaved state On a commit-model platform, what is committed is what boots. There is no `write memory` to forget. A script that reported "0 differences" for these would be technically true and misleading, because it implies a comparison happened. To check those platforms for drift, use `--mode backup` instead, which compares against your last backup and works everywhere.
Before You Start
1. Python 3.9 or newer: python --version 2. The libraries: pip install "netmiko>=4.3" "PyYAML>=6.0" 3. SSH access with enough privilege to read the running configuration. 4. For --mode backup only: a directory already populated by `config-backup`. With no previous backup for a device, there is nothing to compare against.
Your First Run
Create devices.yaml with one device: devices: - host: 192.168.1.1 os: ios Then: export NET_USER='admin' export NET_PASS='your-password' python config_diff.py --inventory devices.yaml That is the unsaved-changes check. [SAME] means running and startup match - the device would survive a reload unchanged. To see what the difference actually is: python config_diff.py --inventory devices.yaml --show-diff And to check for drift since your last backup: python config_diff.py --inventory devices.yaml --mode backup \ --backup-dir ./backups --show-diff
What the Output Means
$ export NET_USER='admin' && export NET_PASS='********' $ python config_diff.py --inventory devices.yaml --show-diff Comparing running vs startup (unsaved changes) on 5 device(s)... ========================================================================== [SAME] sw-access-11 (ios) no difference [DIFF] rtr-dist-02 (ios) +4 -1 (startup -> running) --- startup +++ running @@ -142,6 +142,9 @@ +interface GigabitEthernet0/3 + description LINK-TO-BRANCH-07 + ip address 10.4.9.1 255.255.255.252 + no shutdown -ip route 10.9.0.0 255.255.0.0 10.0.0.9 [SKIP] rtr-core-01 (iosxr) IOS-XR commits to persistent storage - there is no unsaved state Reading it: [SAME] Running and startup are identical once volatile lines are stripped. Nothing would be lost on reload. [DIFF] +4 -1 (startup -> running) Four lines exist in running that are not in startup, and one line is in startup but not running. **The direction matters:** `+` is configuration that would disappear on reload, `-` is configuration that would come back. In the example above, a new branch link was added and a static route removed. On reload the link configuration vanishes and the old route returns - which is worse than either change alone, because the device comes back in a state nobody has ever tested. [SKIP] Platform with no unsaved state. See the previous section. [FAIL] Could not be read. Unknown, not clean. In --mode backup the direction is `backup -> running`, so `+` is what has been added since the backup and `-` is what has been removed.
Exit Codes for Cron and CI
0 no device has unsaved changes (or drift, in backup mode), and every device answered 1 at least one device differs, OR at least one device failed 130 you pressed Ctrl-C Note that `1` covers both "found a difference" and "could not check" on purpose - an unreachable device is not a clean device. Use --json-out to tell them apart. Nightly, as a drift alarm: 0 3 * * * cd /opt/netops && python config_diff.py \ --inventory devices.yaml --save-diffs \ --json-out /var/log/config-drift.json
The Inventory File
devices: - host: 192.168.1.1 # REQUIRED: IP address or DNS name os: ios # REQUIRED: which OS it runs - host: sw-floor2.example.com os: eos port: 2222 # optional, default 22 device_name: Floor 2 IDF # optional label for the output This script uses the shared inventory loader, so a `defaults:` block applies to every device below it unless that device overrides it: defaults: os: ios port: 22 devices: - host: 192.168.1.1 - host: 192.168.1.2 - host: 192.168.1.3 os: nxos # except this one Credentials never go in this file. YAML rules that trip people up: * Indentation must be SPACES, never tabs. * The "- " before host marks a new list item. Keep it aligned. * A space after the colon is required. host: 192.168.1.1 correct host:192.168.1.1 WRONG - YAML parse error
All Valid OS Values
ios Cisco IOS running vs startup iosxe Cisco IOS-XE running vs startup nxos Cisco NX-OS running vs startup eos Arista EOS running vs startup panos Palo Alto PAN-OS running vs saved iosxr Cisco IOS-XR no unsaved state - use --mode backup junos Juniper Junos no unsaved state - use --mode backup fortios Fortinet FortiOS no unsaved state - use --mode backup "cisco" is a vendor, not an OS. Use ios, iosxe, iosxr or nxos.
The Two Modes
--mode startup (default) Running vs the saved configuration, on the device itself. Answers: "what would I lose on a reload?" Needs nothing but SSH. --mode backup Running vs the newest file config-backup wrote for that device, under --backup-dir (default ./backups). Answers: "what has changed since last night?" Works on every platform, including the three with no startup config. The two answer different questions and it is worth running both. A device can be perfectly saved (startup mode clean) and still have been changed by someone yesterday (backup mode dirty).
Credentials
From the environment - never from a file, never from an argument. An argument is visible to every user on the machine in `ps auxww` while the script runs, and it lands in your shell history. NET_USER username required NET_PASS password required NET_ENABLE enable secret optional, falls back to NET_PASS Linux or macOS: export NET_USER='admin' export NET_PASS='your-password' export NET_ENABLE='your-enable-secret' Windows PowerShell: $env:NET_USER = 'admin' $env:NET_PASS = 'your-password' Windows Command Prompt: set NET_USER=admin set NET_PASS=your-password
Every Flag
--inventory PATH REQUIRED. The devices. --mode MODE startup (default) or backup. See The Two Modes. --backup-dir PATH Where config-backup wrote its files. Only used by --mode backup. Default ./backups. --limit TEXT Only hosts whose name contains TEXT. --show-diff Print the changed lines in unified diff format, not just the counts. --context N Unchanged lines shown around each change. Default 2. --save-diffs Write each device's full diff to a timestamped file. This is your change-record evidence. --diff-dir PATH Where --save-diffs writes. Default ./diffs. --read-timeout N Seconds per config download. Default 180. Raise it for very large configurations. --workers N Devices at once. Default 10. --json-out PATH Machine-readable results.
When Something Fails
"Set NET_USER and NET_PASS" Not set in this terminal, or not in the cron job's environment. "authentication failed" Wrong credentials, or the account lacks access. Verify by hand: ssh admin@192.168.1.1 "unreachable or SSH timed out" No answer on the SSH port. If only some devices time out while downloading, raise --read-timeout - two full configurations per device is a lot of output. [SKIP] on IOS-XR, Junos or FortiOS Correct, not a gap. Those platforms have no unsaved state. Use --mode backup to check them for drift. --mode backup reports everything as different Usually the first run, with no previous backup to compare against, or --backup-dir pointing somewhere config-backup never wrote. Check that <backup-dir>/<host>/ exists and has `.cfg` files in it, with the host spelled exactly as in the inventory. A diff that is only timestamps Should not happen - volatile lines are stripped. If you see one, the line is in a format the stripper does not recognise; it is noise, not a change. A device shows unsaved changes you did not make That is the finding. Capture it with --save-diffs before anyone saves or reloads the device, because the moment somebody runs `write memory` the evidence of what was unsaved is gone. You want the changes saved This script will not do it. Read the diff, decide whether running is the version you want, then save deliberately. If running is NOT what you want, the fix is a reload or a restore - not a save.