The final project, and the most production‑shaped one: push a configuration change to many devices at once, but safely — preview the exact diff on each device before committing, and produce a report of what changed everywhere. This is the tool that turns “SSH into forty switches and paste a VLAN” into one reviewed, auditable run. It combines nearly every skill from the series and ends with where to take Python from here.
This is Day 21 of the 21‑post Python for Network Engineers series — the finale.
The Design: Diff Before Commit
The dangerous way to bulk‑push is to blast a config set at every device and hope. The safe way, borrowed from the NAPALM mindset on Day 14, is a three‑phase run:
- Stage the change on each device and capture the diff of what would apply.
- Report every device’s diff for review — nothing is committed yet.
- Commit only after the diffs look right (optionally gated by a confirmation).
NAPALM makes this natural because compare_config() returns the diff and commit_config() is a separate step. This project builds the whole workflow around that pair.
The Inventory and the Change
# targets.yaml
- driver: ios
host: 192.168.1.1
username: admin
password: cisco123
- driver: ios
host: 192.168.1.2
username: admin
password: cisco123
# change.cfg — the config snippet to push everywhere
ntp server 10.0.0.1
logging host 10.0.0.50
Phase 1 and 2: Stage and Collect Diffs
import logging
from pathlib import Path
import yaml
from napalm import get_network_driver
logging.basicConfig(level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S")
log = logging.getLogger("bulkpush")
def stage_change(entry, config_text):
"""Load the candidate config and return (host, diff) without committing."""
host = entry["host"]
driver = get_network_driver(entry["driver"])
dev = driver(hostname=host, username=entry["username"],
password=entry["password"])
try:
dev.open()
dev.load_merge_candidate(config=config_text)
diff = dev.compare_config()
dev.discard_config() # phase 1/2 never commits
return host, diff, None
except Exception as e: # NAPALM raises many driver-specific types
return host, None, str(e)
finally:
dev.close()
def collect_diffs(inventory_path, change_path):
inventory = yaml.safe_load(Path(inventory_path).read_text())
config_text = Path(change_path).read_text()
results = []
for entry in inventory:
host, diff, error = stage_change(entry, config_text)
results.append({"host": host, "diff": diff, "error": error})
if error:
log.error("%s: %s", host, error)
elif diff:
log.info("%s: %d line(s) would change", host, len(diff.splitlines()))
else:
log.info("%s: already compliant, no change", host)
return results
Note that stage_change loads the candidate, reads the diff, and immediately discard_config()s — the staging phase is strictly read‑only in effect. It returns a three‑tuple (host, diff, error) so a driver failure on one device becomes data in the report rather than a crash.
Phase 2: The Diff Report
def print_report(results):
print("\n" + "=" * 60)
print("CHANGE PREVIEW REPORT")
print("=" * 60)
to_change, compliant, failed = 0, 0, 0
for r in results:
print(f"\n--- {r['host']} ---")
if r["error"]:
print(f" ERROR: {r['error']}")
failed += 1
elif r["diff"]:
for line in r["diff"].splitlines():
print(f" {line}")
to_change += 1
else:
print(" (no changes)")
compliant += 1
print("\n" + "-" * 60)
print(f"{to_change} to change, {compliant} compliant, {failed} failed")
return to_change
This report is the deliverable a change window actually needs: every device, the exact lines that will be added or removed, and a summary count. It pastes straight into a change ticket for peer review — the same value compare_config() provided on Day 14, now aggregated across the fleet.
Phase 3: Commit After Review
def commit_change(entry, config_text):
host = entry["host"]
driver = get_network_driver(entry["driver"])
dev = driver(hostname=host, username=entry["username"],
password=entry["password"])
try:
dev.open()
dev.load_merge_candidate(config=config_text)
if dev.compare_config(): # only commit if there's a diff
dev.commit_config()
log.info("%s: committed", host)
return True
dev.discard_config()
log.info("%s: nothing to commit", host)
return False
except Exception as e:
log.error("%s: commit failed - %s", host, e)
return False
finally:
dev.close()
Tie It Together With a Confirmation Gate
def main(inventory_path, change_path, auto_confirm=False):
config_text = Path(change_path).read_text()
inventory = yaml.safe_load(Path(inventory_path).read_text())
results = collect_diffs(inventory_path, change_path)
pending = print_report(results)
if pending == 0:
log.info("nothing to do")
return
if not auto_confirm:
answer = input(f"\nCommit to {pending} device(s)? [y/N] ")
if answer.strip().lower() != "y":
log.info("aborted by operator")
return
committed = sum(commit_change(e, config_text) for e in inventory)
log.info("done: committed %d device(s)", committed)
if __name__ == "__main__":
main("targets.yaml", "change.cfg")
The input() confirmation gate is what makes this safe to hand to someone else: the change is fully previewed, and a human types y only after reading the diffs. The auto_confirm flag allows the same tool to run unattended in a pipeline once the change is trusted. That combination — preview, gate, commit, report — is the shape of real change automation.
What This Tool Demonstrates
Almost the entire series is in this one program: YAML inventory (Day 15), file reading and pathlib (Day 7), NAPALM getters and candidate config (Day 14), exception isolation and logging (Day 7), the fleet loop (Day 13), and a clean function decomposition (Day 6). Nineteen days of building blocks assembled into a tool that would be genuinely useful on a production network.
Where to Go From Here
This series covered the foundation. The next steps, roughly in order:
- Nornir — a pure‑Python automation framework that handles inventory, concurrency, and per‑device tasks. It makes the fleet loops from this series parallel and scalable.
- Concurrency —
concurrent.futures(glimpsed on Day 11) to run against hundreds of devices at once instead of serially. - Testing —
pytestto validate parsing logic and, with tools like pyATS/Genie, to write network state assertions (“BGP has N neighbors, all Established”). - CI/CD for network config — run the backup and push tools from a pipeline, with the diff report as a merge‑request artifact.
- Source of truth — NetBox as the authoritative inventory and IPAM, queried via its REST API (Day 17) to drive Jinja2 (Day 16) config generation.
Every one of those builds directly on the twenty‑one days here. The libraries get more powerful, but the underlying Python — data structures, loops, functions, files, exceptions, and the network libraries — is exactly what this series covered.
Final Exercises
- Warm-up. Modify
print_reportto also write the full report to a timestamped file (report_YYYYMMDD_HHMM.txt) so each run is archived. - Safety. Add a guard to
commit_changethat refuses to commit if the diff contains any line starting with-(a removal), reusing the guardrail idea from Day 14’s challenge. - Rollback file. Before committing on each device, capture the current running‑config (via a NAPALM getter or Netmiko) and save it as
rollback/<host>.cfgso a bad push can be reverted. - Structured report. Emit the report as JSON (Day 15) as well as text, so it can be consumed by another tool or a dashboard.
- Challenge. Combine both projects: after a successful bulk push, trigger the Day 20 backup‑to‑Git tool so the post‑change state is immediately committed — a closed loop of change, verify, and record.
Answers
Show answers
1. Warm-up
from datetime import datetime
def print_report(results):
lines = ["CHANGE PREVIEW REPORT"]
for r in results:
lines.append(f"\n--- {r['host']} ---")
if r["error"]:
lines.append(f" ERROR: {r['error']}")
elif r["diff"]:
lines.extend(f" {l}" for l in r["diff"].splitlines())
else:
lines.append(" (no changes)")
report = "\n".join(lines)
print(report)
Path(f"report_{datetime.now():%Y%m%d_%H%M}.txt").write_text(report)
return sum(1 for r in results if r["diff"])
2. Safety
diff = dev.compare_config()
if any(line.startswith("-") for line in diff.splitlines()):
log.error("%s: refusing - change would remove config", host)
dev.discard_config()
return False
3. Rollback file
Path("rollback").mkdir(exist_ok=True)
current = dev.get_config()["running"]
Path(f"rollback/{host}.cfg").write_text(current)
get_config() returns a dict with running, candidate, and startup keys — capturing running before the commit gives an exact revert point.
4. Structured report
import json
Path("report.json").write_text(json.dumps(results, indent=2))
Because results is already a list of dicts of strings, it serializes to JSON with no extra work — the payoff of keeping data in plain structures throughout.
5. Challenge
# After a successful push, import and run the Day 20 tool:
import backup_to_git # the Day 20 module
committed = sum(commit_change(e, config_text) for e in inventory)
if committed:
backup_to_git.save_configs("targets.yaml")
backup_to_git.git_commit(message=f"Post-change backup ({committed} devices)")
Chaining the two projects closes the loop: push the change, then immediately record the resulting state in Git. Change, verify, record — the core cycle of disciplined network automation, and a fitting place to end the series.
Previously: Project: Config Backup to Git. That completes the 21‑day Python for Network Engineers series — from installing Python to a reviewed, fleet‑wide config‑push tool. The next move is picking one real task on an actual network and automating it.