Loading_
Loading_
Pulls running config from every IOS-XE and NX-OS device, normalises volatile lines, and commits diffs to Git.
#!/usr/bin/env python3"""Cisco configuration backup into Git. Normalises volatile lines so a commit diff represents a real change.Supports IOS, IOS-XE and NX-OS. Usage: python cisco_config_backup.py --inventory devices.yaml --repo /srv/network-configs Author : AIInfraEngineVersion: 3.1.0"""from __future__ import annotations import argparseimport loggingimport osimport reimport subprocessimport sysfrom concurrent.futures import ThreadPoolExecutor, as_completedfrom dataclasses import dataclassfrom pathlib import Path import yamlfrom netmiko import ConnectHandlerfrom netmiko.exceptions import NetmikoAuthenticationException, NetmikoTimeoutException log = logging.getLogger("cisco-backup") # Lines that change on every read and mean nothing in a diff.VOLATILE_PATTERNS = [ re.compile(r"^! Last configuration change at .*$", re.M), re.compile(r"^! NVRAM config last updated at .*$", re.M), re.compile(r"^ntp clock-period \d+$", re.M), re.compile(r"^! Time: .*$", re.M), re.compile(r"^\s*!Running configuration last done at:.*$", re.M), re.compile(r"^\s*!Time: .*$", re.M), re.compile(r"^\s+certificate self-signed [0-9A-F]+.*?quit$", re.M | re.S),] @dataclassclass Device: host: str device_type: str = "cisco_ios" username: str | None = None password: str | None = None secret: str | None = None port: int = 22 site: str = "default" @dataclassclass Result: host: str ok: bool changed: bool = False message: str = "" def normalise(config: str) -> str: """Strip volatile lines and trailing whitespace.""" for pattern in VOLATILE_PATTERNS: config = pattern.sub("", config) lines = [line.rstrip() for line in config.splitlines()] # Collapse runs of blank lines introduced by the substitutions. out: list[str] = [] for line in lines: if not line and out and not out[-1]: continue out.append(line) return "\n".join(out).strip() + "\n" def fetch(device: Device) -> tuple[Device, str]: params = { "device_type": device.device_type, "host": device.host, "port": device.port, "username": device.username or os.environ["NET_USERNAME"], "password": device.password or os.environ["NET_PASSWORD"], "secret": device.secret or os.environ.get("NET_ENABLE", ""), "fast_cli": False, "conn_timeout": 20, "banner_timeout": 20, } with ConnectHandler(**params) as conn: if params["secret"]: conn.enable() command = "show running-config" if "nxos" not in device.device_type else "show running-config" raw = conn.send_command(command, read_timeout=180) return device, raw def write_and_stage(repo: Path, device: Device, config: str) -> bool: """Write the config; return True when the file content changed.""" target = repo / device.site / f"{device.host}.cfg" target.parent.mkdir(parents=True, exist_ok=True) new = normalise(config) old = target.read_text(encoding="utf-8") if target.exists() else None if old == new: return False target.write_text(new, encoding="utf-8") subprocess.run(["git", "-C", str(repo), "add", str(target)], check=True) return True def backup_one(repo: Path, device: Device) -> Result: try: _, raw = fetch(device) except NetmikoAuthenticationException: return Result(device.host, False, message="authentication failed") except NetmikoTimeoutException: return Result(device.host, False, message="unreachable (timeout)") except Exception as exc: # noqa: BLE001 — one bad device must not kill the run return Result(device.host, False, message=f"{type(exc).__name__}: {exc}") if not raw or "Invalid input" in raw: return Result(device.host, False, message="device returned no usable config") changed = write_and_stage(repo, device, raw) return Result(device.host, True, changed=changed, message="changed" if changed else "no change") def commit(repo: Path, changed: list[str]) -> None: if not changed: log.info("No configuration changes to commit.") return summary = f"config backup: {len(changed)} device(s) changed" body = "\n".join(f" - {host}" for host in sorted(changed)) subprocess.run( ["git", "-C", str(repo), "commit", "-m", summary, "-m", body], check=True, ) log.info("Committed changes for %d device(s).", len(changed)) def load_inventory(path: Path) -> list[Device]: data = yaml.safe_load(path.read_text(encoding="utf-8")) return [Device(**entry) for entry in data["devices"]] def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--inventory", type=Path, required=True) parser.add_argument("--repo", type=Path, required=True) parser.add_argument("--workers", type=int, default=12) parser.add_argument("--push", action="store_true", help="git push after committing") parser.add_argument("-v", "--verbose", action="store_true") args = parser.parse_args() logging.basicConfig( level=logging.DEBUG if args.verbose else logging.INFO, format="%(asctime)s %(levelname)-7s %(message)s", ) if not (args.repo / ".git").is_dir(): log.error("%s is not a git repository", args.repo) return 2 devices = load_inventory(args.inventory) log.info("Backing up %d device(s) with %d worker(s)", len(devices), args.workers) results: list[Result] = [] with ThreadPoolExecutor(max_workers=args.workers) as pool: futures = {pool.submit(backup_one, args.repo, d): d for d in devices} for future in as_completed(futures): result = future.result() results.append(result) level = logging.INFO if result.ok else logging.WARNING log.log(level, "%-28s %s", result.host, result.message) changed = [r.host for r in results if r.ok and r.changed] failed = [r.host for r in results if not r.ok] commit(args.repo, changed) if args.push and changed: subprocess.run(["git", "-C", str(args.repo), "push"], check=True) log.info("Pushed to remote.") log.info("Succeeded: %d Changed: %d Failed: %d", len(results) - len(failed), len(changed), len(failed)) return 1 if failed else 0 if __name__ == "__main__": sys.exit(main())Config backup that produces a folder of timestamped files is an archive, not version control. You cannot diff it usefully, and nobody reviews it.
This script commits each device config to Git, which turns "what changed on the core switch last Tuesday" into a one-line query. Volatile lines — NVRAM checksums, uptime counters, certificate timestamps — are stripped so a diff means an actual configuration change.
Devices are polled concurrently with a bounded worker pool. A device that is unreachable is logged and skipped rather than failing the run.
| Name | Type | Required | Description |
|---|---|---|---|
--inventory | path | Required | YAML device inventory. |
--repo | path | Required | Existing Git repository for configs. |
--workers | int | Optional | Concurrent connections. Default 12. |
The platform turns any script into a governed automation — versioned, gated, audited and reversible.