Automation · python · 18 copies
Bulk VLAN Deployment with Consistency Verification
Deploys VLANs across a switching domain and proves the result is consistent. Audits the current VLAN database and trunk allowed-lists first, reports name mismatches and partial presence before changing anything, then creates VLANs with optional SVIs and HSRP/VRRP/anycast gateways. Extends trunk allowed-lists using 'add' so the existing list is never replaced — the mistake that makes a VLAN exist everywhere and work nowhere. Re-audits afterward and fails loudly if the domain is still inconsistent.
- Arista EOS
- Cisco IOS
- Cisco IOS-XE
- Junos
- Cisco NX-OS
pip install "netmiko>=4.3" "PyYAML>=6.0"#!/usr/bin/env python3
"""
Bulk VLAN Deployment with Consistency Verification
READ THE DOCUMENTATION BEFORE YOU RUN THIS:
https://permitany.com/scripts/bulk-vlan-push
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 collections import defaultdict
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("bulkvlan")
NETMIKO_DRIVER = {
"ios": "cisco_ios", "iosxe": "cisco_ios", "nxos": "cisco_nxos",
"eos": "arista_eos", "junos": "juniper_junos", "iosxr": "cisco_xr",
"fortios": "fortinet", "panos": "paloalto_panos",
}
SHOW_VLAN = {
"ios": "show vlan brief", "iosxe": "show vlan brief",
"nxos": "show vlan brief", "eos": "show vlan",
"junos": "show vlans", "fortios": "show system interface",
}
SHOW_TRUNK = {
"ios": "show interfaces trunk", "iosxe": "show interfaces trunk",
"nxos": "show interface trunk", "eos": "show interfaces trunk",
"junos": "show ethernet-switching interface",
}
# Ranges that must never be touched without an explicit override.
RESERVED = [(0, 0), (1, 1), (1002, 1005), (3968, 4047), (4094, 4095)]
# --------------------------------------------------------------------------
# Model
# --------------------------------------------------------------------------
@dataclass
class VlanSpec:
id: int
name: str
svi: str = "" # e.g. "10.20.30.2/24"
gateway: str = "" # HSRP/VRRP virtual IP, or anycast address
gateway_group: int = 1
vrf: str = ""
mtu: int = 0
description: str = ""
trunks: list[str] = field(default_factory=list) # [] means all trunks
@dataclass
class DeviceAudit:
host: str
os: str
present: dict[int, str] = field(default_factory=dict) # vlan id -> name
trunk_allowed: dict[str, set[int]] = field(default_factory=dict)
error: str = ""
@dataclass
class DeviceResult:
host: str
os: str
status: str = "pending" # ok | dry_run | failed | skipped | audit
created: list[int] = field(default_factory=list)
renamed: list[int] = field(default_factory=list)
trunks_updated: list[str] = field(default_factory=list)
already_present: list[int] = field(default_factory=list)
message: str = ""
undo_file: str = ""
elapsed_s: float = 0.0
# --------------------------------------------------------------------------
# Parsing
# --------------------------------------------------------------------------
def parse_vlans(raw: str, os_key: str) -> dict[int, str]:
out: dict[int, str] = {}
if os_key in ("ios", "iosxe", "nxos", "eos"):
for ln in raw.splitlines():
m = re.match(r"^(\d{1,4})\s+(\S+)\s+(active|suspended|act/lshut)", ln)
if m:
out[int(m.group(1))] = m.group(2)
return out
if os_key == "junos":
cur_name = ""
for ln in raw.splitlines():
m = re.match(r"^(\S+)\s+(\d{1,4})\s", ln)
if m:
out[int(m.group(2))] = m.group(1)
continue
m = re.match(r"^\s*(\S+)\s*$", ln)
if m and not m.group(1).isdigit():
cur_name = m.group(1)
m = re.search(r"VLAN ID:\s*(\d+)", ln)
if m and cur_name:
out[int(m.group(1))] = cur_name
return out
return out
def parse_trunks(raw: str, os_key: str) -> dict[str, set[int]]:
"""Map trunk interface -> set of allowed VLAN ids."""
out: dict[str, set[int]] = {}
if os_key not in ("ios", "iosxe", "nxos", "eos") or not raw:
return out
# 'show interfaces trunk' has several sections; the allowed-vlan one is
# identified by its header. Entries can wrap across lines.
in_allowed = False
cur_if = None
for ln in raw.splitlines():
if re.search(r"Vlans allowed on trunk", ln, re.IGNORECASE):
in_allowed = True
continue
if re.search(r"Vlans allowed and active|Vlans in spanning tree", ln, re.IGNORECASE):
in_allowed = False
continue
if not in_allowed:
continue
m = re.match(r"^(\S+)\s+([\d,\-\s]+)$", ln.strip())
if m:
cur_if = m.group(1)
out.setdefault(cur_if, set()).update(expand_ranges(m.group(2)))
elif cur_if and re.match(r"^[\d,\-\s]+$", ln.strip()) and ln.strip():
out[cur_if].update(expand_ranges(ln.strip()))
return out
def expand_ranges(text: str) -> set[int]:
"""'10,20-22,30' -> {10,20,21,22,30}"""
out: set[int] = set()
for part in text.replace(" ", "").split(","):
if not part:
continue
if "-" in part:
try:
a, b = part.split("-", 1)
out.update(range(int(a), int(b) + 1))
except ValueError:
continue
elif part.isdigit():
out.add(int(part))
return out
# --------------------------------------------------------------------------
# Command construction
# --------------------------------------------------------------------------
def is_reserved(vid: int) -> bool:
return any(lo <= vid <= hi for lo, hi in RESERVED)
def build_vlan_cmds(os_key: str, spec: VlanSpec, create: bool, rename: bool) -> list[str]:
cmds: list[str] = []
if os_key in ("ios", "iosxe", "nxos", "eos"):
if create or rename:
cmds += [f"vlan {spec.id}", f" name {spec.name}"]
if spec.svi:
cmds.append(f"interface Vlan{spec.id}")
if spec.description:
cmds.append(f" description {spec.description}")
if spec.vrf:
cmds.append(f" vrf {'member' if os_key == 'nxos' else 'forwarding'} {spec.vrf}")
cmds.append(f" ip address {cidr_to_ios(spec.svi)}")
if spec.mtu:
cmds.append(f" mtu {spec.mtu}")
if spec.gateway:
if os_key == "nxos":
cmds += [f" hsrp {spec.gateway_group}", f" ip {spec.gateway}",
" priority 110", " preempt"]
elif os_key == "eos":
cmds.append(f" ip virtual-router address {spec.gateway}")
else:
cmds += [f" standby {spec.gateway_group} ip {spec.gateway}",
f" standby {spec.gateway_group} priority 110",
f" standby {spec.gateway_group} preempt"]
cmds.append(" no shutdown")
return cmds
if os_key == "junos":
if create or rename:
cmds.append(f"set vlans {spec.name} vlan-id {spec.id}")
if spec.svi:
cmds += [
f"set vlans {spec.name} l3-interface irb.{spec.id}",
f"set interfaces irb unit {spec.id} family inet address {spec.svi}",
]
if spec.description:
cmds.append(f"set interfaces irb unit {spec.id} description \"{spec.description}\"")
if spec.vrf:
cmds.append(f"set routing-instances {spec.vrf} interface irb.{spec.id}")
if spec.gateway:
cmds.append(
f"set interfaces irb unit {spec.id} family inet address {spec.svi} "
f"vrrp-group {spec.gateway_group} virtual-address {spec.gateway}")
return cmds
raise ValueError(f"VLAN creation not supported on os '{os_key}'")
def cidr_to_ios(cidr: str) -> str:
"""'10.20.30.2/24' -> '10.20.30.2 255.255.255.0'"""
import ipaddress
iface = ipaddress.ip_interface(cidr)
return f"{iface.ip} {iface.network.netmask}"
def build_trunk_cmds(os_key: str, trunk: str, vids: list[int]) -> list[str]:
"""Always ADD to the allowed list; never replace it."""
vid_str = ",".join(str(v) for v in sorted(vids))
if os_key in ("ios", "iosxe", "eos"):
return [f"interface {trunk}", f" switchport trunk allowed vlan add {vid_str}"]
if os_key == "nxos":
return [f"interface {trunk}", f" switchport trunk allowed vlan add {vid_str}"]
if os_key == "junos":
return [f"set interfaces {trunk} unit 0 family ethernet-switching "
f"vlan members {v}" for v in sorted(vids)]
raise ValueError(f"trunk update not supported on os '{os_key}'")
def build_undo(os_key: str, created: list[int], trunks: dict[str, list[int]],
specs: dict[int, VlanSpec]) -> list[str]:
out: list[str] = []
for trunk, vids in trunks.items():
vid_str = ",".join(str(v) for v in sorted(vids))
if os_key in ("ios", "iosxe", "nxos", "eos"):
out += [f"interface {trunk}", f" switchport trunk allowed vlan remove {vid_str}"]
elif os_key == "junos":
out += [f"delete interfaces {trunk} unit 0 family ethernet-switching "
f"vlan members {v}" for v in sorted(vids)]
for vid in created:
if os_key in ("ios", "iosxe", "nxos", "eos"):
out += [f"no interface Vlan{vid}", f"no vlan {vid}"]
elif os_key == "junos":
out.append(f"delete vlans {specs[vid].name}")
return out
# --------------------------------------------------------------------------
# Audit
# --------------------------------------------------------------------------
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 audit(dev: dict) -> DeviceAudit:
a = DeviceAudit(host=dev["host"], os=dev["os"])
conn = None
try:
conn = connect(dev)
if SHOW_VLAN.get(dev["os"]):
a.present = parse_vlans(conn.send_command(SHOW_VLAN[dev["os"]], read_timeout=90),
dev["os"])
if SHOW_TRUNK.get(dev["os"]):
a.trunk_allowed = parse_trunks(conn.send_command(SHOW_TRUNK[dev["os"]],
read_timeout=90), dev["os"])
except NetmikoAuthenticationException as exc:
a.error = f"authentication failed: {exc}"
except NetmikoTimeoutException as exc:
a.error = f"timeout: {exc}"
except Exception as exc: # noqa: BLE001
a.error = f"{type(exc).__name__}: {exc}"
finally:
if conn:
try:
conn.disconnect()
except Exception: # noqa: BLE001
pass
return a
def domain_report(audits: list[DeviceAudit], specs: list[VlanSpec]) -> list[str]:
"""Describe inconsistency across the domain, independent of the intended change."""
issues: list[str] = []
reachable = [a for a in audits if not a.error]
if not reachable:
return ["no device could be audited"]
# Name mismatches for the same VLAN id
names: dict[int, dict[str, list[str]]] = defaultdict(lambda: defaultdict(list))
for a in reachable:
for vid, name in a.present.items():
names[vid][name].append(a.host)
for vid, by_name in sorted(names.items()):
if len(by_name) > 1:
detail = "; ".join(f"'{n}' on {len(h)} ({', '.join(h[:3])})"
for n, h in by_name.items())
issues.append(f"VLAN {vid} has inconsistent names: {detail}")
# Partial presence of the VLANs we care about
wanted = {s.id for s in specs}
for vid in sorted(wanted):
have = [a.host for a in reachable if vid in a.present]
if have and len(have) != len(reachable):
missing = [a.host for a in reachable if vid not in a.present]
issues.append(f"VLAN {vid} present on {len(have)}/{len(reachable)} switches, "
f"missing on: {', '.join(missing[:5])}")
# Trunks that do not carry a VLAN the switch has
for a in reachable:
for vid in sorted(set(a.present) & wanted):
gaps = [t for t, allowed in a.trunk_allowed.items() if vid not in allowed]
if gaps:
issues.append(f"{a.host}: VLAN {vid} exists but is not allowed on "
f"{len(gaps)} trunk(s): {', '.join(gaps[:3])}")
return issues
# --------------------------------------------------------------------------
# Push
# --------------------------------------------------------------------------
def process(dev: dict, specs: list[VlanSpec], pre: DeviceAudit,
args, stamp: str) -> DeviceResult:
res = DeviceResult(host=dev["host"], os=dev["os"])
t0 = time.time()
conn = None
try:
if pre.error:
res.status, res.message = "failed", pre.error
return res
cmds: list[str] = []
trunk_plan: dict[str, list[int]] = defaultdict(list)
spec_by_id = {s.id: s for s in specs}
for s in specs:
if is_reserved(s.id) and not args.allow_reserved:
res.message = f"VLAN {s.id} is reserved; use --allow-reserved to override"
res.status = "failed"
return res
existing = pre.present.get(s.id)
if existing is None:
cmds += build_vlan_cmds(dev["os"], s, create=True, rename=False)
res.created.append(s.id)
elif existing != s.name:
if not args.allow_rename:
res.already_present.append(s.id)
LOG.warning("%s: VLAN %d exists as '%s', wanted '%s' "
"(use --allow-rename to change)",
dev["host"], s.id, existing, s.name)
continue
cmds += build_vlan_cmds(dev["os"], s, create=False, rename=True)
res.renamed.append(s.id)
else:
res.already_present.append(s.id)
# Trunk allowed-list extension
targets = s.trunks or list(pre.trunk_allowed.keys())
for t in targets:
if s.id not in pre.trunk_allowed.get(t, set()):
trunk_plan[t].append(s.id)
for trunk, vids in trunk_plan.items():
cmds += build_trunk_cmds(dev["os"], trunk, vids)
res.trunks_updated.append(trunk)
if not cmds:
res.status = "skipped"
res.message = "already in the desired state"
return res
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"! Removes {len(res.created)} VLAN(s) and reverts "
f"{len(res.trunks_updated)} trunk(s)",
"!", *build_undo(dev["os"], res.created, trunk_plan, spec_by_id), ""]),
encoding="utf-8")
res.undo_file = str(p)
if not args.apply:
res.status = "dry_run"
res.message = (f"would create {len(res.created)}, rename {len(res.renamed)}, "
f"update {len(res.trunks_updated)} trunk(s)")
return res
conn = connect(dev)
out = conn.send_config_set(cmds, read_timeout=240)
for marker in ("% Invalid", "% Incomplete", "ERROR:", "Invalid input", "error:"):
if marker in out:
res.status = "failed"
res.message = f"device rejected config ({marker}); undo at {res.undo_file}"
return res
# Verify on-box before claiming success
time.sleep(args.settle_seconds)
after = parse_vlans(conn.send_command(SHOW_VLAN[dev["os"]], read_timeout=90), dev["os"])
missing = [s.id for s in specs if s.id not in after]
if missing:
res.status = "failed"
res.message = f"VLANs still missing after push: {missing}; undo at {res.undo_file}"
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"created {len(res.created)}, renamed {len(res.renamed)}, "
f"{len(res.trunks_updated)} trunk(s) updated")
except ValueError as exc:
res.status, res.message = "failed", str(exc)
except NetmikoTimeoutException as exc:
res.status, res.message = "failed", f"timeout: {exc} - VERIFY DEVICE STATE"
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
# --------------------------------------------------------------------------
# CLI
# --------------------------------------------------------------------------
def load_specs(path: str) -> list[VlanSpec]:
raw = Path(path).read_text(encoding="utf-8")
data = yaml.safe_load(raw) if (yaml and path.endswith((".yaml", ".yml"))) else json.loads(raw)
items = data["vlans"] if isinstance(data, dict) else data
return [VlanSpec(**v) for v in items]
def render(results: list[DeviceResult], issues_before: list[str],
issues_after: list[str], args) -> int:
icon = {"ok": "[OK]", "dry_run": "[DRY]", "failed": "[FAIL]",
"skipped": "[SKIP]", "audit": "[AUDIT]"}
if issues_before:
print(f"\n{'=' * 78}\nDOMAIN INCONSISTENCY BEFORE CHANGE ({len(issues_before)})")
for i in issues_before[:20]:
print(f" ! {i}")
if len(issues_before) > 20:
print(f" ... {len(issues_before) - 20} more")
if args.audit_only:
print(f"\n{'=' * 78}\n Audit only. No change attempted.")
return 1 if issues_before else 0
print(f"\n{'=' * 78}")
for r in sorted(results, key=lambda x: x.host):
print(f"{icon.get(r.status, '[?]'):<8} {r.host:<20} {r.os:<7} "
f"{r.elapsed_s:>5.1f}s {r.message}")
if r.created:
print(f" created: {', '.join(str(v) for v in r.created)}")
if r.trunks_updated:
print(f" trunks: {', '.join(r.trunks_updated[:6])}")
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(" " + " ".join(f"{k}={v}" for k, v in sorted(tally.items())))
if issues_after:
print(f"\n DOMAIN STILL INCONSISTENT ({len(issues_after)} issue(s)):")
for i in issues_after[:10]:
print(f" ! {i}")
return 1
if args.apply:
print("\n Domain consistent across all reachable switches.")
else:
print("\n Dry-run. Re-run with --apply to write.")
return 1 if tally.get("failed") else 0
def main() -> int:
ap = argparse.ArgumentParser(description="Deploy VLANs across a switching domain.")
ap.add_argument("--inventory", required=True)
ap.add_argument("--vlans", required=True, help="YAML/JSON list of VLAN specs")
ap.add_argument("--apply", action="store_true", help="Write changes (default dry-run)")
ap.add_argument("--audit-only", action="store_true",
help="Only report current domain consistency; never build a change")
ap.add_argument("--allow-rename", action="store_true",
help="Rename a VLAN that exists with a different name")
ap.add_argument("--allow-reserved", action="store_true",
help="Permit reserved VLAN ids (1, 1002-1005, 3968-4047, 4094)")
ap.add_argument("--undo-dir", default="./undo")
ap.add_argument("--settle-seconds", type=int, default=5)
ap.add_argument("--workers", type=int, default=8)
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 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
specs = load_specs(args.vlans)
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
LOG.info("%s | %d VLAN(s) | %d switch(es)",
"AUDIT" if args.audit_only else ("APPLY" if args.apply else "DRY-RUN"),
len(specs), len(devices))
LOG.info("Auditing current VLAN state ...")
audits: dict[str, DeviceAudit] = {}
with ThreadPoolExecutor(max_workers=args.workers) as pool:
futs = {pool.submit(audit, d): d for d in devices}
for fut in as_completed(futs):
d = futs[fut]
try:
a = fut.result()
except Exception as exc: # noqa: BLE001
a = DeviceAudit(host=d["host"], os=d.get("os", "?"), error=str(exc))
audits[d["host"]] = a
issues_before = domain_report(list(audits.values()), specs)
results: list[DeviceResult] = []
if not args.audit_only:
with ThreadPoolExecutor(max_workers=args.workers) as pool:
futs = {pool.submit(process, d, specs, audits[d["host"]], 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}"))
issues_after: list[str] = []
if args.apply and results:
LOG.info("Re-auditing to confirm domain consistency ...")
post: list[DeviceAudit] = []
with ThreadPoolExecutor(max_workers=args.workers) as pool:
futs = {pool.submit(audit, d): d for d in devices}
for fut in as_completed(futs):
try:
post.append(fut.result())
except Exception: # noqa: BLE001
pass
issues_after = domain_report(post, specs)
if args.json_out:
Path(args.json_out).write_text(json.dumps({
"issues_before": issues_before,
"issues_after": issues_after,
"results": [asdict(r) for r in results],
}, indent=2), encoding="utf-8")
return render(results, issues_before, issues_after, args)
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
print("\nInterrupted. Re-run with --audit-only to check domain state.", file=sys.stderr)
sys.exit(130)
--inventorytextrequiredYAML/JSON inventory of switches in the L2 domain, each with host and os.
--vlanstextrequiredYAML/JSON list of VLAN specs: id, name, and optional svi, gateway, gateway_group, vrf, mtu, description, trunks.
--applyboolWrite changes. Omit for dry-run, which is the default.
--audit-onlyboolOnly report current domain consistency — name mismatches, partial presence, trunk gaps. Never builds a change.
--allow-renameboolRename a VLAN that already exists under a different name. Off by default; a rename can break documentation and monitoring.
--allow-reservedboolPermit reserved VLAN ids (1, 1002-1005, 3968-4047, 4094). Off by default.
--undo-dirtextWhere per-device undo fragments are written. Default: ./undo
--settle-secondsnumberPause before on-box verification. Default: 5.
--workersnumberConcurrent switches. Default: 8.
--json-outtextMachine-readable results including the before and after consistency issues.
What This Solves
Adding a VLAN to a switching domain is four commands on one switch and a long afternoon on twenty. The work is not the typing, it is the consistency: the VLAN has to exist on every switch, under the same name, and be allowed on every trunk that has to carry it. Miss one trunk and the VLAN exists everywhere and works nowhere. Name it `USERS` on four switches and `USER_DATA` on the fifth and you will find out months later, during an outage, when somebody greps for the wrong string. This script treats the domain as one object instead of twenty devices: It audits before it touches anything. Reads the VLAN database and the trunk allowed-lists on every switch and tells you what is already inconsistent. Often that report alone is the reason to run it. It extends trunk allowed-lists, never replaces them. Every trunk command uses `add`. Typing `switchport trunk allowed vlan 300` instead of `... vlan add 300` replaces the entire list and drops every other VLAN off that trunk - the classic way to turn a routine VLAN addition into a site-wide outage. It proves the result instead of assuming it. Re-reads the VLAN database on the box after pushing, and re-audits the whole domain at the end. If the domain is still inconsistent it says so and exits non-zero. Use it to add VLANs across a campus or a data centre pod, and to audit a domain you inherited before you trust it.
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. A dry run connects, audits, builds the exact command list and shows you what it would do. That is the mode to use first, every time. Five more things stand between you and an accident: --audit-only Reads only. Never builds or sends a config command. Safe on any production domain at any time. Undo files Before anything is written, a per-device undo fragment is saved to ./undo (change it with --undo-dir). It contains the exact commands to reverse what this run did: `no vlan`, `no interface Vlan`, and `switchport trunk allowed vlan remove`. It is written during a dry run too, so you can read the rollback before you ever apply. Reserved VLANs 1, 1002-1005, 3968-4047 and 4094 are refused unless you pass --allow-reserved. Renames are off A VLAN that exists under a different name is reported, not renamed, unless you pass --allow-rename. A rename is not cosmetic: it can break documentation, automation and monitoring that match on the name. On-box verification After pushing, it waits --settle-seconds, re-reads the VLAN database, and marks the device FAILED if the VLANs are not actually there - rather than trusting that the CLI accepted the commands. What it does write, when you pass --apply: `vlan`/`name`, optionally an SVI with address, description, VRF, MTU and an HSRP/VRRP/anycast gateway, and `switchport trunk allowed vlan add` on the trunks. On IOS, IOS-XE and EOS it then runs `write memory`; on NX-OS, `copy running-config startup-config`. On Junos the configuration is committed by the session. **The saved config is the point of no return.** Up to that moment a reload would have discarded the change. After it, the undo file is your rollback.
Before You Start
1. Python 3.9 or newer: python --version 2. The two libraries it imports: pip install "netmiko>=4.3" "PyYAML>=6.0" 3. SSH access with an account that can enter configuration mode. Prove it by hand on one switch first: ssh admin@192.168.1.1 show vlan brief configure terminal 4. Know which trunks matter. By default a VLAN is added to **every trunk the switch currently has**. That is usually what you want in a campus and usually not what you want at a border. Pin it per VLAN with the `trunks:` field - see THE VLAN FILE.
Your First Run
Three passes, in this order. Do not skip to the third. PASS 1 - audit, reads only Create devices.yaml with your switches: devices: - host: 192.168.1.11 os: ios - host: 192.168.1.12 os: ios Create vlans.yaml with what you want to exist: vlans: - id: 300 name: IOT Then: export NET_USER='admin' export NET_PASS='your-password' python bulk_vlan_push.py --inventory devices.yaml --vlans vlans.yaml --audit-only Nothing is written. You get the current state of the domain. Read it - if it reports inconsistencies you did not know about, deal with those first. PASS 2 - dry run python bulk_vlan_push.py --inventory devices.yaml --vlans vlans.yaml Still nothing written. Every device shows [DRY] and what it would create. Open the undo files under ./undo and read them. If the rollback does not look like the exact reverse of what you intended, stop. PASS 3 - apply, one switch first Cut devices.yaml down to a single non-critical switch, then: python bulk_vlan_push.py --inventory devices.yaml --vlans vlans.yaml --apply Confirm on the box by hand. Then put the rest of the switches back and run it again - the ones already done report [SKIP], because the script compares state rather than replaying commands.
What the Output Means
$ export NET_USER='admin' && export NET_PASS='********' $ python bulk_vlan_push.py --inventory devices.yaml --vlans vlans.yaml --apply Domain inconsistency before change (3) ! VLAN 120 has inconsistent names: 'USERS' on 4 (sw-01, sw-02, sw-03); 'USER_DATA' on 1 (sw-05) ! VLAN 300 present on 4/6 switches, missing on: sw-floor4-01, sw-floor5-01 ! sw-02: VLAN 120 exists but is not allowed on 2 trunk(s): Po1, Po2 [OK] sw-floor4-01 ios 14.2s created 2, renamed 0, 2 trunk(s) updated created: 300, 310 trunks: Po1, Po2 undo: ./undo/sw-floor4-01-20260115-152233.cfg [SKIP] sw-01 ios 3.1s already in the desired state ok=4 skipped=2 Domain consistent across all reachable switches. The three blocks, in order: DOMAIN INCONSISTENCY BEFORE CHANGE What was already wrong when the script arrived. This is a finding about your network, not about this run. The three shapes it reports: inconsistent names the same VLAN id under different names partial presence the VLAN exists on some switches, not all trunk gaps the VLAN exists but is not allowed on a trunk Per-device results [OK] changes written and verified on the box [DRY] dry run - this is what it would have done [SKIP] already in the desired state, nothing sent [FAIL] rejected by the device, or the VLANs were not there when re-read afterwards. The undo file path is on the line [?] an unexpected state - treat as a failure and read --json-out The closing line `Domain consistent across all reachable switches` is the one that matters. **Reachable** is doing real work in that sentence: a switch that did not answer is not consistent, it is unknown. Check the tally line above it for errors before you believe it. If the re-audit still finds problems you get `DOMAIN STILL INCONSISTENT` with the remaining issues, and the run exits non-zero even though individual devices may have reported [OK].
Exit Codes for Cron and CI
0 everything applied and the domain re-audited clean, or a dry run with nothing failing, or --audit-only with no issues found 1 a device failed, or the domain is still inconsistent afterwards, or --audit-only found issues 2 NET_USER / NET_PASS are not set 130 you pressed Ctrl-C `--audit-only` returning 1 is the useful one: run it nightly and it becomes a drift alarm for the whole switching domain. 0 2 * * * cd /opt/netops && python bulk_vlan_push.py \ --inventory devices.yaml --vlans vlans.yaml \ --audit-only --json-out /var/log/vlan-drift.json
The Inventory File
Which switches are in the domain. Plain YAML or JSON. devices: - host: 192.168.1.11 # REQUIRED: IP address or DNS name os: ios # REQUIRED: which OS it runs - host: sw-floor2.example.com os: eos - host: 192.168.1.13 os: nxos port: 2222 # optional, default 22 A bare list without the `devices:` key works too. Those three fields are all this script reads. It does **not** support a `defaults:` block, and it does **not** take a username or password from this file - credentials come from the environment only. (Some other PermitANY scripts do support `defaults:`; this one does not.) Supported platforms VLAN creation and trunk updates are implemented for five platforms: ios Cisco IOS Catalyst switches iosxe Cisco IOS-XE Catalyst 9000 nxos Cisco NX-OS Nexus eos Arista EOS all Arista switches junos Juniper Junos EX, QFX Anything else - including `iosxr`, `panos` and `fortios` - is refused with `VLAN creation not supported on os '<x>'`. Those platforms are valid in other PermitANY scripts; they are not switching platforms in this sense, so they are not valid here. Gateways differ by platform, and the script picks the right one: HSRP on IOS/IOS-XE and NX-OS, `ip virtual-router address` (anycast) on EOS, and a VRRP group on Junos. The vlan file What you want to exist. Only `id` and `name` are required. vlans: - id: 300 name: IOT - id: 310 name: CAMERAS description: Building 4 camera network svi: 10.31.0.2/24 # this switch's own address gateway: 10.31.0.1 # HSRP/VRRP/anycast virtual address gateway_group: 10 # default 1 vrf: CORP mtu: 9216 trunks: # omit to use EVERY trunk - Port-channel1 - Port-channel2 Two fields deserve care: svi Each switch needs its OWN address in the subnet, so a single value here is only correct if you are pushing to one switch at a time. For a multi-switch SVI deployment, run per switch with a vlans file each, or leave `svi` out and add the addresses separately. trunks Omitted means **every trunk the switch currently has**. On a campus access layer that is usually right. At a border, a data-centre edge, or anywhere with a transit trunk, name the trunks explicitly. A bare list without the `vlans:` key works too.
Credentials
From the environment, never from a file and never from an argument. An argument is visible in `ps auxww` while the script runs and lands in your shell history; a file gets committed to git. NET_USER username required NET_PASS password required NET_ENABLE enable secret optional, used on ios/iosxe/nxos 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 They last only for the current terminal session, so the password never lands on disk.
Every Flag
--inventory PATH REQUIRED. The switches. --vlans PATH REQUIRED. What should exist. --apply Write. Without it, dry run - which is the default and where you should start. --audit-only Report current consistency and stop. Never builds a config command. Reads only. --allow-rename Rename a VLAN that exists under another name. Off by default: a rename can break monitoring and automation that match on the name. --allow-reserved Permit 1, 1002-1005, 3968-4047, 4094. Off by default. --undo-dir PATH Where the per-device undo fragments go. Default ./undo. --settle-seconds N Pause before re-reading the box to verify. Default 5. Raise it on a busy chassis. --workers N Switches in parallel. Default 8. --json-out PATH Machine-readable results, including the before and after consistency issues. This is the change record worth keeping.
When Something Fails
"Set NET_USER and NET_PASS in the environment." (exit 2) Not set in this terminal. See Credentials. "VLAN creation not supported on os 'iosxr'" That platform is not a switching platform for this script. See Supported Platforms. Remove it from the inventory. "device rejected config (% Invalid)" The switch refused a command. Nothing further was sent to it, and the undo file named on that line covers whatever did land. The usual causes: the account cannot enter configuration mode, VTP is in server mode and refusing client-side VLAN edits, or the trunk named in `trunks:` does not exist on that switch. "VLANs still missing after push" The device accepted the commands and the VLANs are not in the database. This is what the on-box verification exists to catch. Normally VTP: in client mode the switch takes the command and ignores it. Check `show vtp status` before anything else. DOMAIN STILL INCONSISTENT after a successful-looking run Individual switches reported [OK] but the domain as a whole did not converge. Usually one switch was unreachable and is still missing the VLAN, or a trunk between two switches is allowed on one end only. The listed issues name the devices. A trunk was not updated If `trunks:` is set, only those are touched, and a name must match the device's own spelling - `Po1` and `Port-channel1` are not the same string here. If `trunks:` is omitted, the script uses the trunks it found in `show interfaces trunk`; a link that is not currently trunking does not appear there. Everything reports [SKIP] when you expected changes The desired state already exists. That is a correct answer. Confirm with --audit-only, which reports what is actually on the switches. You need to undo a run The undo fragments are under ./undo, one per device, timestamped. They are plain command lists - read them first, then paste or push them. They reverse what THAT run did, so use the fragment from the run you want to reverse, not the newest one.