Automation · python · 9 copies
Bulk Interface Configuration
Applies an interface profile to many ports across many switches with blast-radius control. Discovers live interface state, selects ports by name, description, VLAN or operational status, and automatically excludes trunks, port-channel members and uplink-named ports unless you opt in. Refuses to run when the selection exceeds a configurable cap, writes a per-device undo fragment reconstructed from the prior config, and verifies every port reached the intended state before saving.
- 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
"""
Bulk Interface Configuration
READ THE DOCUMENTATION BEFORE YOU RUN THIS:
https://permitany.com/scripts/bulk-interface-config
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 logging
import os
import re
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
try:
import yaml
except ImportError:
yaml = None
from netmiko import ConnectHandler
from netmiko.exceptions import NetmikoAuthenticationException, NetmikoTimeoutException
LOG = logging.getLogger("bulkintf")
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",
}
# Commands used to discover the current interface inventory and state.
DISCOVER = {
"ios": {"brief": "show ip interface brief", "status": "show interfaces status",
"switch": "show interfaces switchport", "po": "show etherchannel summary",
"run_intf": "show running-config | section ^interface"},
"iosxe": {"brief": "show ip interface brief", "status": "show interfaces status",
"switch": "show interfaces switchport", "po": "show etherchannel summary",
"run_intf": "show running-config | section ^interface"},
"nxos": {"brief": "show ip interface brief", "status": "show interface status",
"switch": "show interface switchport", "po": "show port-channel summary",
"run_intf": "show running-config interface"},
"eos": {"brief": "show ip interface brief", "status": "show interfaces status",
"switch": "show interfaces switchport", "po": "show port-channel summary",
"run_intf": "show running-config | section interface"},
"iosxr": {"brief": "show ipv4 interface brief", "status": "show interfaces brief",
"switch": "", "po": "show bundle brief",
"run_intf": "show running-config interface"},
"junos": {"brief": "show interfaces terse", "status": "show interfaces descriptions",
"switch": "show ethernet-switching interface", "po": "show interfaces ae0 terse",
"run_intf": "show configuration interfaces | display set"},
"fortios": {"brief": "get system interface physical", "status": "get system interface",
"switch": "", "po": "", "run_intf": "show system interface"},
"panos": {"brief": "show interface all", "status": "show interface all",
"switch": "", "po": "", "run_intf": "show config running | match interface"},
}
# Interfaces that are structurally unsafe to bulk-edit.
UPLINK_HINTS = re.compile(
r"(uplink|trunk|core|spine|wan|mpls|peer|mlag|vpc|port-?channel|bundle|ae\d|po\d)",
re.IGNORECASE,
)
# --------------------------------------------------------------------------
# Model
# --------------------------------------------------------------------------
@dataclass
class Interface:
name: str
description: str = ""
status: str = "" # connected | notconnect | disabled | up | down
vlan: str = ""
mode: str = "" # access | trunk | routed
in_portchannel: bool = False
current_cfg: list[str] = field(default_factory=list)
@property
def is_uplink(self) -> bool:
return (
self.in_portchannel
or self.mode == "trunk"
or bool(UPLINK_HINTS.search(self.name))
or bool(UPLINK_HINTS.search(self.description))
)
@dataclass
class DeviceResult:
host: str
os: str
status: str = "pending" # ok | dry_run | failed | skipped | aborted
discovered: int = 0
selected: list[str] = field(default_factory=list)
excluded: dict[str, str] = field(default_factory=dict)
verified: int = 0
message: str = ""
undo_file: str = ""
elapsed_s: float = 0.0
# --------------------------------------------------------------------------
# Discovery and parsing
# --------------------------------------------------------------------------
def parse_status_table(raw: str, os_key: str) -> dict[str, Interface]:
"""Parse `show interfaces status` style output into Interface objects."""
out: dict[str, Interface] = {}
if os_key in ("ios", "iosxe", "nxos", "eos"):
for ln in raw.splitlines():
if not ln.strip() or ln.lstrip().startswith(("Port", "---", "Name")):
continue
# Name Description(may contain spaces) Status Vlan Duplex Speed Type
m = re.match(
r"^(\S+)\s+(.*?)\s+(connected|notconnect|disabled|err-disabled|monitoring|inactive)"
r"\s+(\S+)\s+", ln)
if not m:
continue
name, desc, status, vlan = m.groups()
out[name] = Interface(name=name, description=desc.strip(),
status=status, vlan=vlan)
return out
if os_key == "junos":
# show interfaces descriptions: Interface Admin Link Description
for ln in raw.splitlines():
m = re.match(r"^(\S+)\s+(up|down)\s+(up|down)\s*(.*)$", ln)
if m:
name, admin, link, desc = m.groups()
out[name] = Interface(name=name, description=desc.strip(),
status="connected" if link == "up" else "notconnect")
return out
if os_key == "iosxr":
for ln in raw.splitlines():
m = re.match(r"^(\S+)\s+(up|down|admin-down)\s+(up|down|admin-down)", ln)
if m:
name, admin, _ = m.groups()
out[name] = Interface(name=name,
status="connected" if admin == "up" else "notconnect")
return out
if os_key == "fortios":
cur = None
for ln in raw.splitlines():
m = re.match(r"^==\s*\[\s*(\S+)\s*\]", ln)
if m:
cur = m.group(1)
out[cur] = Interface(name=cur)
elif cur and "status:" in ln:
out[cur].status = "connected" if "up" in ln else "notconnect"
return out
return out
def enrich_switchport(ifaces: dict[str, Interface], raw: str, os_key: str) -> None:
"""Fill mode and access VLAN from `show interfaces switchport`."""
if os_key not in ("ios", "iosxe", "nxos", "eos") or not raw:
return
cur = None
for ln in raw.splitlines():
m = re.match(r"^Name:\s*(\S+)", ln)
if m:
cur = m.group(1)
continue
if not cur or cur not in ifaces:
continue
m = re.search(r"Operational Mode:\s*(\S+)", ln)
if m:
mode = m.group(1).lower()
ifaces[cur].mode = "trunk" if "trunk" in mode else (
"access" if "access" in mode else "routed")
m = re.search(r"Access Mode VLAN:\s*(\d+)", ln)
if m:
ifaces[cur].vlan = m.group(1)
def enrich_portchannel(ifaces: dict[str, Interface], raw: str, os_key: str) -> None:
if not raw:
return
for i in ifaces.values():
short = i.name.replace("GigabitEthernet", "Gi").replace("TenGigabitEthernet", "Te")
if re.search(rf"\b{re.escape(short)}\b", raw) or re.search(rf"\b{re.escape(i.name)}\b", raw):
i.in_portchannel = True
def capture_current(ifaces: dict[str, Interface], raw: str, os_key: str) -> None:
"""Record each interface's existing config block, used to build the undo file."""
if not raw:
return
cur = None
for ln in raw.splitlines():
m = re.match(r"^interface\s+(\S+)", ln, re.IGNORECASE)
if m:
cur = m.group(1)
continue
if ln.strip() in ("!", "exit"):
cur = None
continue
if cur and cur in ifaces and ln.startswith((" ", "\t")):
ifaces[cur].current_cfg.append(ln.rstrip())
def discover(conn, os_key: str) -> dict[str, Interface]:
cmds = DISCOVER.get(os_key, {})
status_raw = conn.send_command(cmds["status"], read_timeout=90) if cmds.get("status") else ""
ifaces = parse_status_table(status_raw, os_key)
if cmds.get("switch"):
enrich_switchport(ifaces, conn.send_command(cmds["switch"], read_timeout=120), os_key)
if cmds.get("po"):
enrich_portchannel(ifaces, conn.send_command(cmds["po"], read_timeout=60), os_key)
if cmds.get("run_intf"):
capture_current(ifaces, conn.send_command(cmds["run_intf"], read_timeout=180), os_key)
return ifaces
# --------------------------------------------------------------------------
# Selection
# --------------------------------------------------------------------------
def select(ifaces: dict[str, Interface], args) -> tuple[list[Interface], dict[str, str]]:
chosen: list[Interface] = []
rejected: dict[str, str] = {}
for name, i in sorted(ifaces.items()):
if args.match_name and not re.search(args.match_name, name):
continue
if args.exclude_name and re.search(args.exclude_name, name):
rejected[name] = "exclude-name"
continue
if args.match_desc and not re.search(args.match_desc, i.description, re.IGNORECASE):
continue
if args.exclude_desc and re.search(args.exclude_desc, i.description, re.IGNORECASE):
rejected[name] = "exclude-desc"
continue
if args.match_vlan and i.vlan != str(args.match_vlan):
continue
if args.match_status:
want = args.match_status
if want == "up" and i.status != "connected":
continue
if want == "down" and i.status != "notconnect":
continue
if want == "admin-down" and i.status not in ("disabled", "admin-down"):
continue
if i.is_uplink and not args.include_uplinks:
rejected[name] = "uplink/trunk/po-member"
continue
chosen.append(i)
return chosen, rejected
# --------------------------------------------------------------------------
# Profile rendering
# --------------------------------------------------------------------------
def render_profile(profile: dict, os_key: str, iface: Interface,
overrides: dict[str, Any]) -> list[str]:
body = profile.get("config", {}).get(os_key)
if not body:
raise ValueError(f"profile '{profile.get('name')}' has no body for os '{os_key}'")
ctx = dict(profile.get("vars", {}))
ctx.update(overrides)
ctx.setdefault("interface", iface.name)
ctx.setdefault("description", iface.description)
lines: list[str] = []
for raw in body:
try:
lines.append(raw.format(**ctx))
except KeyError as exc:
raise ValueError(
f"profile line '{raw}' references undefined variable {exc}") from exc
return lines
def build_commands(os_key: str, iface: Interface, body: list[str]) -> list[str]:
if os_key in ("ios", "iosxe", "nxos", "eos", "iosxr"):
return [f"interface {iface.name}", *body]
if os_key == "junos":
return [b if b.startswith(("set ", "delete ")) else
f"set interfaces {iface.name} {b}" for b in body]
if os_key == "fortios":
return ["config system interface", f" edit {iface.name}", *[f" {b}" for b in body],
" next", "end"]
if os_key == "panos":
return [b if b.startswith("set ") else f"set network interface {iface.name} {b}"
for b in body]
raise ValueError(f"unsupported os '{os_key}'")
def build_undo(os_key: str, iface: Interface) -> list[str]:
"""Reconstruct the interface's prior config so the change can be reversed."""
if os_key in ("ios", "iosxe", "nxos", "eos", "iosxr"):
if not iface.current_cfg:
return [f"! {iface.name}: prior config not captured, restore from snapshot"]
return [f"default interface {iface.name}", f"interface {iface.name}",
*iface.current_cfg]
if os_key == "junos":
return [f"! {iface.name}: use 'rollback 1' on the device"]
return [f"! {iface.name}: restore from the pre-change snapshot"]
# --------------------------------------------------------------------------
# Verification
# --------------------------------------------------------------------------
def verify(conn, os_key: str, selected: list[Interface], profile: dict) -> tuple[int, list[str]]:
"""Confirm each port reached the state the profile intended."""
expect = profile.get("verify", {})
if not expect:
return len(selected), []
cmd = expect.get("command")
pattern = expect.get("expect")
if not cmd or not pattern:
return len(selected), []
ok, bad = 0, []
for i in selected:
out = conn.send_command(cmd.format(interface=i.name), read_timeout=60)
if re.search(pattern.format(interface=i.name), out, re.IGNORECASE):
ok += 1
else:
bad.append(i.name)
return ok, bad
# --------------------------------------------------------------------------
# Device workflow
# --------------------------------------------------------------------------
def connect(dev: dict):
params = {
"device_type": NETMIKO_DRIVER[dev["os"]],
"host": dev["host"],
"username": os.environ.get("NET_USER", ""),
"password": os.environ.get("NET_PASS", ""),
"port": dev.get("port", 22),
"conn_timeout": 20,
"fast_cli": False,
}
if os.environ.get("NET_ENABLE"):
params["secret"] = os.environ["NET_ENABLE"]
conn = ConnectHandler(**params)
if params.get("secret") and dev["os"] in ("ios", "iosxe", "nxos"):
conn.enable()
return conn
def process(dev: dict, profile: dict, args, stamp: str) -> DeviceResult:
res = DeviceResult(host=dev["host"], os=dev["os"])
t0 = time.time()
conn = None
try:
conn = connect(dev)
ifaces = discover(conn, dev["os"])
res.discovered = len(ifaces)
chosen, rejected = select(ifaces, args)
res.selected = [i.name for i in chosen]
res.excluded = rejected
if not chosen:
res.status = "skipped"
res.message = f"no interface matched (of {len(ifaces)} discovered)"
return res
if len(chosen) > args.max_change:
res.status = "aborted"
res.message = (f"selection of {len(chosen)} exceeds --max-change {args.max_change}; "
f"narrow the filters or raise the cap deliberately")
return res
# Build the full command set and the matching undo fragment
cmds: list[str] = []
undo: list[str] = []
overrides = dev.get("vars", {})
for i in chosen:
body = render_profile(profile, dev["os"], i, overrides)
cmds.extend(build_commands(dev["os"], i, body))
undo.extend(build_undo(dev["os"], i))
if args.undo_dir:
d = Path(args.undo_dir)
d.mkdir(parents=True, exist_ok=True)
p = d / f"{dev['host']}-{stamp}.cfg"
p.write_text("\n".join([
f"! Undo for {dev['host']} ({dev['os']}) generated {stamp}",
f"! Reverts profile '{profile.get('name')}' on {len(chosen)} interface(s)",
"!", *undo, ""]), encoding="utf-8")
res.undo_file = str(p)
if not args.apply:
res.status = "dry_run"
res.message = f"{len(chosen)} interface(s) would change, {len(rejected)} excluded"
return res
out = conn.send_config_set(cmds, read_timeout=240)
for marker in ("% Invalid", "% Incomplete", "% Ambiguous", "ERROR:", "Invalid input"):
if marker in out:
res.status = "failed"
res.message = f"device rejected config ({marker}); undo at {res.undo_file}"
return res
time.sleep(args.settle_seconds)
ok, bad = verify(conn, dev["os"], chosen, profile)
res.verified = ok
if bad:
res.status = "failed"
res.message = (f"{len(bad)} interface(s) failed verification: "
f"{', '.join(bad[:6])}{'...' if len(bad) > 6 else ''}")
return res
if dev["os"] in ("ios", "iosxe", "eos"):
conn.send_command_timing("write memory")
elif dev["os"] == "nxos":
conn.send_command_timing("copy running-config startup-config")
res.status = "ok"
res.message = f"{len(chosen)} interface(s) configured and verified"
except NetmikoAuthenticationException as exc:
res.status, res.message = "failed", f"authentication failed: {exc}"
except NetmikoTimeoutException as exc:
res.status, res.message = "failed", f"timeout: {exc} - VERIFY DEVICE STATE MANUALLY"
except ValueError as exc:
res.status, res.message = "failed", f"profile error: {exc}"
except Exception as exc: # noqa: BLE001
res.status, res.message = "failed", f"{type(exc).__name__}: {exc}"
finally:
res.elapsed_s = round(time.time() - t0, 1)
if conn:
try:
conn.disconnect()
except Exception: # noqa: BLE001
pass
return res
# --------------------------------------------------------------------------
# Reporting
# --------------------------------------------------------------------------
def render(results: list[DeviceResult], args) -> int:
icon = {"ok": "[OK]", "dry_run": "[DRY]", "failed": "[FAIL]",
"skipped": "[SKIP]", "aborted": "[ABORT]"}
total_if = 0
for r in sorted(results, key=lambda x: x.host):
total_if += len(r.selected)
print(f"\n{icon.get(r.status, '[?]'):<8} {r.host:<20} {r.os:<7} "
f"{len(r.selected):>3}/{r.discovered:<3} intf {r.elapsed_s:>5.1f}s {r.message}")
if r.selected and args.show_interfaces:
for chunk in (r.selected[i:i + 6] for i in range(0, len(r.selected), 6)):
print(f" {' '.join(c.ljust(14) for c in chunk)}")
if r.excluded and args.show_excluded:
for name, why in list(r.excluded.items())[:10]:
print(f" excluded {name:<16} ({why})")
if r.undo_file:
print(f" undo: {r.undo_file}")
tally: dict[str, int] = {}
for r in results:
tally[r.status] = tally.get(r.status, 0) + 1
print(f"\n{'=' * 78}")
print(f" {len(results)} device(s) {total_if} interface(s) in scope")
print(" " + " ".join(f"{k}={v}" for k, v in sorted(tally.items())))
if tally.get("aborted"):
print("\n Aborted devices exceeded --max-change. Review the selection filters.")
bad = tally.get("failed", 0) + tally.get("aborted", 0)
if not args.apply and tally.get("dry_run"):
print("\n Dry-run. Re-run with --apply to write these changes.")
return 1 if bad else 0
def main() -> int:
ap = argparse.ArgumentParser(description="Apply an interface profile in bulk, safely.")
ap.add_argument("--inventory", required=True)
ap.add_argument("--profile", required=True, help="YAML file of named interface profiles")
ap.add_argument("--profile-name", required=True, help="Which profile to apply")
sel = ap.add_argument_group("selection")
sel.add_argument("--match-name", help="Regex the interface name must match")
sel.add_argument("--match-desc", help="Regex the description must match")
sel.add_argument("--match-vlan", help="Only ports in this access VLAN")
sel.add_argument("--match-status", choices=["up", "down", "admin-down"])
sel.add_argument("--exclude-name", help="Regex of names to skip")
sel.add_argument("--exclude-desc", help="Regex of descriptions to skip")
sel.add_argument("--include-uplinks", action="store_true",
help="Allow trunks, port-channel members and uplink-named ports")
safe = ap.add_argument_group("safety")
safe.add_argument("--apply", action="store_true", help="Write changes (default is dry-run)")
safe.add_argument("--max-change", type=int, default=24,
help="Abort a device if more interfaces than this are selected. Default 24")
safe.add_argument("--settle-seconds", type=int, default=5)
safe.add_argument("--undo-dir", default="./undo", help="Where undo fragments are written")
ap.add_argument("--workers", type=int, default=6)
ap.add_argument("--show-interfaces", action="store_true")
ap.add_argument("--show-excluded", action="store_true")
ap.add_argument("--json-out")
ap.add_argument("-v", "--verbose", action="store_true")
args = ap.parse_args()
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s %(levelname)-7s %(message)s", datefmt="%H:%M:%S")
if not any([args.match_name, args.match_desc, args.match_vlan, args.match_status]):
LOG.error("At least one --match-* filter is required. "
"Refusing to select every interface on every device.")
return 2
if not os.environ.get("NET_USER") or not os.environ.get("NET_PASS"):
LOG.error("Set NET_USER and NET_PASS in the environment.")
return 2
raw = Path(args.inventory).read_text(encoding="utf-8")
data = (yaml.safe_load(raw) if yaml and args.inventory.endswith((".yaml", ".yml"))
else json.loads(raw))
devices = data["devices"] if isinstance(data, dict) else data
praw = Path(args.profile).read_text(encoding="utf-8")
pdata = yaml.safe_load(praw) if yaml else json.loads(praw)
profiles = {p["name"]: p for p in (pdata["profiles"] if isinstance(pdata, dict) else pdata)}
if args.profile_name not in profiles:
LOG.error("Profile '%s' not found. Available: %s",
args.profile_name, ", ".join(sorted(profiles)))
return 2
profile = profiles[args.profile_name]
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
LOG.info("%s | profile '%s' | %d device(s) | cap %d intf/device",
"APPLY" if args.apply else "DRY-RUN", args.profile_name,
len(devices), args.max_change)
if args.apply:
LOG.warning("Live change. Undo fragments will be written to %s", args.undo_dir)
results: list[DeviceResult] = []
with ThreadPoolExecutor(max_workers=args.workers) as pool:
futs = {pool.submit(process, d, profile, args, stamp): d for d in devices}
for fut in as_completed(futs):
d = futs[fut]
try:
results.append(fut.result())
except Exception as exc: # noqa: BLE001
LOG.exception("%s: worker crashed", d["host"])
results.append(DeviceResult(host=d["host"], os=d.get("os", "?"),
status="failed", message=f"crashed: {exc}"))
if args.json_out:
Path(args.json_out).write_text(
json.dumps([asdict(r) for r in results], indent=2), encoding="utf-8")
return render(results, args)
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
print("\nInterrupted. Devices mid-write may be partially configured - verify.",
file=sys.stderr)
sys.exit(130)
--inventorytextrequiredYAML/JSON inventory with host and os per device. Optional per-device vars override profile variables.
--profiletextrequiredYAML file of named interface profiles, each with per-OS command bodies and an optional verify block.
--profile-nametextrequiredWhich profile from that file to apply.
--match-nametextRegex the interface name must match, e.g. 'Gi1/0/(1[0-9]|2[0-4])'. At least one --match-* filter is mandatory.
--match-desctextRegex the interface description must match.
--match-vlantextOnly ports currently in this access VLAN.
--match-statustextOperational state filter: up, down, or admin-down.
--exclude-nametextRegex of interface names to skip.
--exclude-desctextRegex of descriptions to skip.
--include-uplinksboolAllow trunks, port-channel members and uplink-named ports. Off by default — these are the ports that take a floor down.
--applyboolWrite changes. Omit for dry-run, which is the default.
--max-changenumberAbort a device if more interfaces than this are selected. Default: 24.
--undo-dirtextWhere per-device undo fragments are written. Default: ./undo
--settle-secondsnumberPause before verification so ports come up. Default: 5.
--show-interfacesboolPrint the resolved interface list per device.
--show-excludedboolPrint which interfaces were excluded and why.
--workersnumberConcurrent devices. Default: 6.
--json-outtextMachine-readable results.
What This Solves
You need the same interface configuration on a lot of ports: a new voice VLAN on every access port in a building, 802.1X on a floor, storm control across a campus, a description standard nobody ever finished applying. Doing it by hand is slow. Doing it with a for-loop over `interface range` is fast and is how people take sites down, because the loop does not know which of those ports is the uplink carrying everything back to the core. This script is the same bulk edit with the blast radius controlled: It discovers before it selects. Reads the live interface list - name, description, status, access VLAN, trunk/access mode, port-channel membership - and selects from what is actually there, not from a range you typed from memory. It refuses to touch uplinks by default. Any port that is a trunk, is in a port-channel, or whose name or description matches uplink, core, spine, wan, mpls, peer, mlag, vpc, port-channel, bundle, ae<n> or po<n> is excluded. You can override it with --include-uplinks, which is a deliberate act. It caps the damage. If more than --max-change interfaces are selected on a device (default 24), that device is ABORTED without being touched. A filter that was meant to match 12 ports and matched 38 stops there. It writes the undo first. The prior configuration of every selected port is read off the box and turned into a rollback fragment before anything is sent. Use it for an access-layer rollout, a standards cleanup, or any change that is identical on many ports and catastrophic on the wrong one.
Is It Safe to Run
It writes to devices - but only when you explicitly ask it to. DRY-RUN IS THE DEFAULT. Without --apply nothing is written. Four more guards, all on by default: A filter is mandatory With no --match-name, --match-desc, --match-vlan or --match-status the script exits 2 rather than selecting every interface on every device. Uplinks excluded See above. --show-excluded prints exactly what was skipped and why, which is worth reading before your first apply. --max-change 24 Per device. Exceeded means ABORTED, not truncated - it does not configure the first 24 and stop. Undo fragments Written to ./undo (change with --undo-dir) before anything is sent, and during a dry run too. On IOS, IOS-XE, NX-OS, EOS and IOS-XR they are real rollbacks, reconstructed from the port's running config as `default interface` followed by the previous lines. On Junos the fragment tells you to use `rollback 1` on the device; on FortiOS and PAN-OS it tells you to restore from a snapshot, because the prior state is not reconstructed there. **If you are on those three platforms, take your own backup first.** After writing, it waits --settle-seconds and runs the profile's `verify` command on each port, if the profile defines one. A port that does not match is reported and the device is marked FAILED. **A profile with no verify block is not verified** - the script reports success on the basis that the CLI accepted the commands. Write a verify block. On IOS, IOS-XE and EOS it then runs `write memory`; on NX-OS, `copy running-config startup-config`. If you press Ctrl-C mid-apply the script says so explicitly: devices already being written may be partially configured. Verify them by hand.
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 an account that can enter configuration mode. 4. A profile file. This script does not know what you want to configure - you tell it, once, in YAML. See The Profile File.
Your First Run
Four steps. The first three write nothing. STEP 1 - inventory, one switch devices: - host: 192.168.1.11 os: ios STEP 2 - a profile profiles: - name: access-voice config: ios: - " switchport mode access" - " switchport access vlan {data_vlan}" - " switchport voice vlan {voice_vlan}" - " spanning-tree portfast" - " spanning-tree bpduguard enable" vars: data_vlan: 120 voice_vlan: 220 verify: command: "show running-config interface {interface}" expect: "switchport voice vlan 220" STEP 3 - see what it would select, and what it would skip export NET_USER='admin' export NET_PASS='your-password' python bulk_interface_config.py \ --inventory devices.yaml \ --profile profiles.yaml --profile-name access-voice \ --match-name 'Gi1/0/(1[0-9]|2[0-4])' \ --show-interfaces --show-excluded Nothing is written. **Read the excluded list.** If a port you expected is missing it is almost always the uplink exclusion doing its job - or a description containing the word "core" on a port that is not one. STEP 4 - apply to that one switch ... same command ... --apply Check the ports by hand, then widen the inventory and run it again.
What the Output Means
$ export NET_USER='admin' && export NET_PASS='********' $ python bulk_interface_config.py --inventory devices.yaml \ --profile profiles.yaml --profile-name access-voice \ --match-name 'Gi1/0/(1[0-9]|2[0-4])' --show-interfaces --show-excluded --apply [OK] sw-floor2-01 ios 15/48 intf 12.3s 15 interface(s) configured and verified Gi1/0/10 Gi1/0/11 Gi1/0/12 Gi1/0/13 Gi1/0/14 Gi1/0/15 Gi1/0/16 Gi1/0/17 Gi1/0/18 Gi1/0/19 Gi1/0/20 Gi1/0/21 excluded Gi1/0/23 (uplink/trunk/po-member) excluded Gi1/0/24 (uplink/trunk/po-member) undo: ./undo/sw-floor2-01-20260115-150412.cfg [ABORT] sw-core-01 nxos 38/96 intf 2.4s selection of 38 exceeds --max-change 24; narrow the filters or raise the cap deliberately ============================================================================== 3 device(s) 67 interface(s) in scope aborted=1 ok=2 Reading it: 15/48 intf 15 selected out of 48 discovered. The ratio is the sanity check - if it reads 46/48 on an access switch, your filter is too broad and you are about to reconfigure the uplinks' neighbours. The statuses [OK] written and, if the profile has a verify block, verified [DRY] dry run - this is what it would have done [SKIP] nothing matched the filters on this device [ABORT] the selection exceeded --max-change. NOTHING was sent to this device. It is not a partial application [FAIL] the device rejected the config, or ports did not verify excluded ... (reason) Only shown with --show-excluded. The reasons are `uplink/trunk/ po-member`, `exclude-name` and `exclude-desc`. The first is the one to read carefully - it is the guard, and occasionally it is wrong about a port whose description happens to contain "peer" or "core". undo: ./undo/... The rollback for this device, for this run. An ABORT is a safety stop, not an error in the usual sense - but it exits non-zero, because a run that did not do what you asked should not look successful to a pipeline.
Exit Codes for Cron and CI
0 everything applied (or dry-ran) with no failures and no aborts 1 a device failed, or a device was aborted on --max-change 2 no --match-* filter given, NET_USER/NET_PASS not set, or the named profile does not exist in the profile file 130 you pressed Ctrl-C - devices mid-write may be partially configured, verify them This is not a script to put on a cron. The exit codes are for a pipeline where a human approved the change and wants the result gated.
The Inventory File
devices: - host: 192.168.1.11 # REQUIRED: IP address or DNS name os: ios # REQUIRED: which OS it runs - host: 192.168.1.12 os: nxos port: 2222 # optional, default 22 vars: # optional, overrides the profile's vars data_vlan: 130 # on this device only A bare list without the `devices:` key works too. `host`, `os`, `port` and `vars` are the four fields this script reads. There is no `defaults:` block, and credentials never come from this file. `vars` is the useful one: the same profile with a different VLAN per site, without a profile per site.
Supported Platforms
ios Cisco IOS full support, real undo iosxe Cisco IOS-XE full support, real undo nxos Cisco NX-OS full support, real undo eos Arista EOS full support, real undo iosxr Cisco IOS-XR full support, real undo junos Juniper Junos undo fragment says `rollback 1` fortios Fortinet FortiOS no reconstructed undo - snapshot first panos Palo Alto PAN-OS no reconstructed undo - snapshot first Discovery differs per platform and the script sends the right commands. Switchport and port-channel discovery do not exist on IOS-XR, FortiOS and PAN-OS, so on those three the uplink guard falls back to matching the interface name and description only - it cannot see that a port is a trunk. **Be more specific with your filters on those platforms.**
The Profile File
What to configure. One file can hold many profiles; --profile-name picks one. profiles: - name: access-voice config: ios: - " switchport mode access" - " switchport access vlan {data_vlan}" - " switchport voice vlan {voice_vlan}" - " spanning-tree portfast" nxos: - " switchport mode access" - " switchport access vlan {data_vlan}" - " spanning-tree port type edge" vars: data_vlan: 120 voice_vlan: 220 verify: command: "show running-config interface {interface}" expect: "switchport access vlan 120" How it fits together: config.<os> The command body, per platform, WITHOUT the `interface <name>` line - the script adds that. A profile with no body for a device's OS fails that device with a clear message rather than guessing. vars Substituted into `{braces}` in the body. Device-level `vars` in the inventory override these. Two names are always available without declaring them: `{interface}` and `{description}`, taken from the live port. A `{name}` that is not defined anywhere is an error naming the line, not a silent empty string. verify Optional and strongly recommended. `command` is run per port and `expect` is a regex matched against the output. Both accept `{interface}`. Without this block the script cannot tell you whether the change actually took. A bare list without the `profiles:` key works too.
Selecting the Right Ports
At least one --match-* is required. They AND together, and the excludes are applied on top. --match-name REGEX Against the interface name as the device spells it: 'Gi1/0/(1[0-9]|2[0-4])' --match-desc REGEX Against the description, case-insensitive --match-vlan N Ports currently in that access VLAN --match-status STATE up, down, or admin-down. `up` means the device reports the port connected; `down` means notconnect --exclude-name REGEX Skip these names --exclude-desc REGEX Skip these descriptions Two patterns worth knowing: Re-tag only what is already in the old VLAN: --match-vlan 110 Only ports nothing is plugged into, for a disruptive change: --match-name 'Gi1/0/' --match-status down
Credentials
From the environment, never from a file and never from an argument - an argument is visible in `ps auxww` and lands in shell history. NET_USER username required NET_PASS password required NET_ENABLE enable secret optional, used on ios/iosxe/nxos export NET_USER='admin' export NET_PASS='your-password' 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. --profile PATH REQUIRED. The profile file. --profile-name NAME REQUIRED. Which profile in it. --match-name REGEX At least one --match-* is required. --match-desc REGEX --match-vlan N --match-status STATE up | down | admin-down --exclude-name REGEX --exclude-desc REGEX --include-uplinks Allow trunks, port-channel members and uplink-named ports. Off by default. --apply Write. Without it, dry run. --max-change N Per-device cap. Default 24. Exceeding it aborts that device untouched. --undo-dir PATH Default ./undo. --settle-seconds N Pause before verification. Default 5. --show-interfaces Print the resolved port list per device. --show-excluded Print what was skipped and why. Read this before your first apply. --workers N Devices in parallel. Default 6. --json-out PATH Machine-readable results - the change record.
When Something Fails
"At least one --match-* filter is required." (exit 2) Deliberate. Without a filter the selection is every interface on every device, and there is no safe default for that. "Profile 'x' not found. Available: ..." (exit 2) The --profile-name does not match a `name:` in the profile file. The message lists what is there. "profile 'x' has no body for os 'nxos'" That device's platform has no `config.nxos` block. Add one, or remove the device from this run. The script will not translate a profile between platforms. "profile line '...' references undefined variable 'x'" A `{placeholder}` in the body with no matching entry in `vars` or in the device's `vars`. The message names the exact line. "selection of N exceeds --max-change" Working as intended. Narrow the filters, or raise the cap on purpose after reading the list with --show-interfaces. Nothing was sent. A port you expected was excluded Run with --show-excluded. If the reason is `uplink/trunk/po-member`, the port is a trunk, is in a port-channel, or its name or description contains one of: uplink, trunk, core, spine, wan, mpls, peer, mlag, vpc, port-channel, bundle, ae<n>, po<n>. A description like "to core printer" trips it. Either fix the description or use --include-uplinks with a tighter --match-name. Ports were configured but reported as not verified The commands were accepted and the verify regex did not match. Check the `expect` pattern against the real output first - a wrong regex looks exactly like a failed change. If the pattern is right, the change genuinely did not take. Everything says [OK] but nothing was verified The profile has no `verify` block, so there was nothing to check. Add one. You need to undo a run The fragments are under ./undo, one per device, timestamped. On IOS, IOS-XE, NX-OS, EOS and IOS-XR they are complete: `default interface` followed by the port's previous configuration. On Junos use `rollback 1` on the device. On FortiOS and PAN-OS restore from your own snapshot - the fragment will say so.