Automation · python · 103 copies
Infoblox VLAN / Network Provisioner
Provisions a new VLAN on an Infoblox NIOS grid as the five objects it really is -- the network, the DHCP range, the DHCP options, the reverse zone and the gateway records -- from one YAML template plus a list of VLANs, over WAPI v2.x. Creates them in topological order, because a range cannot exist before its network and a PTR cannot exist before its reverse zone, and reports what DID get created when a step fails. Refuses a DHCP pool that would hand out the gateway, computes the one reverse zone a /24 needs and the two a /23 needs, and detects the /25 case it cannot create at all because RFC 2317 delegation has to come from the parent. Dry run by default; writes only with --apply.
- NIOS / WAPI v2.x
pip install "requests>=2.28" "PyYAML>=6.0"#!/usr/bin/env python3
"""
Infoblox VLAN / Network Provisioner
READ THE DOCUMENTATION BEFORE YOU RUN THIS:
https://permitany.com/scripts/infoblox-network-provision
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 "requests>=2.28" "PyYAML>=6.0"
"""
# NOT read-only. With --apply this script CREATES networks, DHCP ranges,
# reverse zones and gateway records on the grid. Without --apply it writes
# nothing: it reads, plans and prints. It never sends DELETE.
#
# EXIT CODES 0 plan clean / every write succeeded
# 1 something needs a human: blocked steps, conflicts, failures
# 2 the run could not proceed at all
#
# CREDENTIALS come from the environment, never from an argument:
# IB_USER / IB_PASS (not needed at all with --offline-dir)
#
# NIOS / WAPI v2.x. Not Universal DDI.
import argparse
import csv
import ipaddress
import json
import os
import re
import sys
from datetime import datetime, timezone
try:
import requests
from requests.auth import HTTPBasicAuth
except ImportError:
sys.stderr.write("requests is not installed. Run: pip install 'requests>=2.28'\n")
raise SystemExit(2)
try:
import yaml
except ImportError:
sys.stderr.write("PyYAML is not installed. Run: pip install 'PyYAML>=6.0'\n")
raise SystemExit(2)
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
DEFAULT_WAPI = "v2.12"
# ---------------------------------------------------------------- outcomes
CREATE = "CREATE"
UPDATE = "UPDATE"
SKIP = "SKIP"
BLOCKED = "BLOCKED"
LABEL_RE = re.compile(r"^(?!-)[A-Za-z0-9_-]{1,63}(?<!-)$")
# ---------------------------------------------------------------- transport
class Wapi(object):
"""Live WAPI, or a replay of saved responses. Same interface either way.
Offline replay is not a convenience. It is how this script was tested
without a grid, and it is how you can hand a colleague the exact data a
finding came from.
"""
def __init__(self, host=None, version=DEFAULT_WAPI, insecure=False,
offline_dir=None, save_dir=None):
self.offline_dir = offline_dir
self.save_dir = save_dir
self.base = None
self.session = None
if offline_dir:
if not os.path.isdir(offline_dir):
raise SystemExit("--offline-dir does not exist: %s" % offline_dir)
return
user = os.environ.get("IB_USER")
password = os.environ.get("IB_PASS")
if not user or not password:
raise SystemExit(
"IB_USER and IB_PASS must be set in the environment.\n"
" Linux/macOS : export IB_USER=admin; read -s IB_PASS; export IB_PASS\n"
" PowerShell : $env:IB_USER='admin'; $env:IB_PASS='...'\n"
"They are read from the environment on purpose: an argument is "
"visible in 'ps auxww' and lands in shell history."
)
self.base = "https://%s/wapi/%s/" % (host, version)
self.session = requests.Session()
self.session.auth = HTTPBasicAuth(user, password)
self.session.verify = not insecure
if insecure:
try:
import urllib3
urllib3.disable_warnings()
except Exception:
pass
@staticmethod
def _slug(obj, params):
"""Filename for one query. Must survive Windows, so everything that is
not alphanumeric becomes a dash -- '>' and ':' are legal in a WAPI
search key and illegal in an NTFS filename."""
def clean(text):
return "".join(c if c.isalnum() else "-" for c in str(text))
bits = [clean(obj)]
for k in sorted(params):
if k.startswith("_"):
continue
bits.append("%s=%s" % (clean(k), clean(params[k])))
return ("__".join(bits) or "root")[:180] + ".json"
def get(self, obj, params=None):
"""Return a list of objects, following WAPI paging to the end.
Paging in WAPI only works with _return_as_object=1 -- without it the
next_page_id never comes back and you silently get the first page and
nothing else. _max_results is NEGATIVE on purpose: a positive value
makes WAPI raise 'Result set too large' instead of truncating, which
turns a big zone into a failed run rather than a partial one.
"""
params = dict(params or {})
slug = self._slug(obj, params)
if self.offline_dir:
path = os.path.join(self.offline_dir, slug)
if not os.path.exists(path):
return []
with open(path, encoding="utf-8") as fh:
return json.load(fh)
params["_return_as_object"] = 1
params["_paging"] = 1
params["_max_results"] = -1000
out = []
page = None
while True:
call = dict(params)
if page:
call = {"_page_id": page, "_return_as_object": 1}
resp = self.session.get(self.base + obj, params=call, timeout=60)
if resp.status_code == 401:
raise SystemExit("WAPI rejected IB_USER/IB_PASS (401).")
if resp.status_code >= 400:
raise SystemExit("WAPI %s on %s: %s" % (resp.status_code, obj, resp.text[:300]))
body = resp.json()
out.extend(body.get("result", []))
page = body.get("next_page_id")
if not page:
break
if self.save_dir:
if not os.path.isdir(self.save_dir):
os.makedirs(self.save_dir)
with open(os.path.join(self.save_dir, slug), "w", encoding="utf-8") as fh:
json.dump(out, fh, indent=1)
return out
# -- the write half. Everything above this line is shared verbatim with
# -- the read-only Infoblox scripts; only this part can change a grid.
def post(self, obj, payload):
"""Create one object. Returns (ok, ref_or_error).
One object per call on purpose. WAPI does have a /request endpoint
that batches, but a batch fails as a unit and leaves you guessing
which step did it. One call per step costs round trips and buys an
exact rollback file.
"""
if self.offline_dir:
return True, "SIMULATED/%s/%s" % (obj, payload.get("name")
or payload.get("ptrdname")
or payload.get("network")
or payload.get("fqdn")
or payload.get("start_addr"))
try:
resp = self.session.post(self.base + obj, json=payload, timeout=60)
except requests.exceptions.RequestException as exc:
return False, str(exc)
if resp.status_code in (200, 201):
return True, resp.json() # WAPI returns the new _ref, as a string
return False, self._error(resp)
def put(self, ref, payload):
"""Change one existing object in place. Returns (ok, ref_or_error)."""
if self.offline_dir:
return True, "SIMULATED/" + ref
try:
resp = self.session.put(self.base + ref, json=payload, timeout=60)
except requests.exceptions.RequestException as exc:
return False, str(exc)
if resp.status_code == 200:
return True, resp.json()
return False, self._error(resp)
@staticmethod
def _error(resp):
"""WAPI puts the useful sentence in a JSON 'text' field and the useless
one in the HTTP reason. Prefer the former, fall back to the body."""
try:
body = resp.json()
return "%s: %s" % (resp.status_code, body.get("text") or body)
except ValueError:
return "%s: %s" % (resp.status_code, resp.text[:200])
# ---------------------------------------------------------------- helpers
def dashed(value):
return "-" if value in (None, "") else str(value)
def is_suffix_zone(name, zone):
"""True when `name` lives inside `zone`, by label, not by characters.
Comparing with endswith() alone matches 'evilexample.com' against
'example.com', which is how a check like this quietly stops working.
"""
name = (name or "").rstrip(".").lower()
zone = (zone or "").rstrip(".").lower()
return bool(zone) and (name == zone or name.endswith("." + zone))
def valid_fqdn(name):
"""Syntax only. A name can be perfectly well formed and still have no zone
on this grid -- that is a separate check, and it needs the grid."""
name = (name or "").rstrip(".")
if not name or len(name) > 253 or "." not in name:
return False
return all(LABEL_RE.match(label) for label in name.split("."))
def reverse_zones_for(net):
"""The in-addr.arpa zone names a prefix needs, and whether this script can
create them at all.
-> (list_of_zone_fqdns, rfc2317_parent_or_None)
Three cases, and getting them confused is the whole point of this
function:
/24 exactly one zone.
/23 and shorter one zone PER /24 inside it. A /22 needs FOUR. This is
where people create one zone and wonder why half the
addresses have no reverse.
/25 and longer RFC 2317 classless. The zone cannot be created
unilaterally: the owner of the parent /24 has to
delegate it first. The second element of the tuple is
that parent, and the caller must say so rather than
letting WAPI fail.
"""
if net.prefixlen > 24:
parent = ipaddress.ip_network("%s/24" % net.network_address, strict=False)
o = str(parent.network_address).split(".")
return [], "%s.%s.%s.in-addr.arpa" % (o[2], o[1], o[0])
zones = []
for sub in net.subnets(new_prefix=24) if net.prefixlen < 24 else [net]:
o = str(sub.network_address).split(".")
zones.append("%s.%s.%s.in-addr.arpa" % (o[2], o[1], o[0]))
return zones, None
# ---------------------------------------------------------------- DHCP options
# name -> option code. The names are the ones NIOS itself shows, so a template
# written from the GUI transfers across without a translation table.
DHCP_OPTIONS = {
"subnet-mask": 1,
"time-offset": 2,
"routers": 3,
"time-servers": 4,
"domain-name-servers": 6,
"host-name": 12,
"domain-name": 15,
"broadcast-address": 28,
"ntp-servers": 42,
"vendor-encapsulated-options": 43,
"netbios-name-servers": 44,
"netbios-node-type": 46,
"dhcp-lease-time": 51,
"tftp-server-name": 66,
"bootfile-name": 67,
"domain-search": 119,
}
# NIOS shows a handful of options separately from the rest and gives them a
# "use" flag. For those, WAPI honours use_option; for every other option the
# field is ignored and sending it is noise. Sending it on the wrong option is
# harmless, omitting it on these is NOT -- the value is stored and not used.
USE_OPTION_FLAG = {
"routers", "domain-name-servers", "domain-name",
"broadcast-address", "dhcp-lease-time",
}
IP_LIST_OPTIONS = {"routers", "domain-name-servers", "time-servers",
"ntp-servers", "netbios-name-servers", "broadcast-address"}
INT_OPTIONS = {"dhcp-lease-time", "time-offset", "netbios-node-type"}
def option_payload(name, value):
"""One entry of a WAPI `options` list."""
entry = {"name": name, "num": DHCP_OPTIONS[name],
"value": value, "vendor_class": "DHCP"}
if name in USE_OPTION_FLAG:
entry["use_option"] = True
return entry
def options_as_map(options):
"""A WAPI options list -> {name: value}, keeping only the DHCP vendor
class. Comparing the raw lists is useless: NIOS returns the inherited
options as well as the ones set here, in whatever order it likes."""
out = {}
for o in options or []:
if (o.get("vendor_class") or "DHCP") != "DHCP":
continue
name = o.get("name")
if name:
out[name] = str(o.get("value"))
return out
# ---------------------------------------------------------------- the input
TEMPLATE_KEYS = {
"gateway", "reserve_low", "reserve_high", "dhcp", "dhcp_member",
"exclusions", "options", "network_comment", "range_comment", "extattrs",
"gateway_record", "create_reverse_zone", "reverse_zone_ns_group",
"view", "network_view",
}
TEMPLATE_DEFAULTS = {
"gateway": "first",
"reserve_low": 10,
"reserve_high": 5,
"dhcp": True,
"dhcp_member": None,
"exclusions": [],
"options": {"network": {}, "range": {}},
"network_comment": "VLAN {vlan} {name}",
"range_comment": "VLAN {vlan} {name} DHCP pool",
"extattrs": {},
"gateway_record": None,
"create_reverse_zone": True,
"reverse_zone_ns_group": None,
"view": "default",
"network_view": "default",
}
CSV_OVERRIDES = ("gateway", "reserve_low", "reserve_high", "dhcp",
"dhcp_member", "view", "network_view")
def merge_template(base, over):
"""Shallow merge, except `options`, which merges one level deeper so a
per-network override of options.range does not silently delete
options.network."""
out = dict(base)
for key, val in (over or {}).items():
if key == "options" and isinstance(val, dict):
opts = {"network": dict(out["options"].get("network") or {}),
"range": dict(out["options"].get("range") or {})}
for scope in ("network", "range"):
if isinstance(val.get(scope), dict):
opts[scope].update(val[scope])
out["options"] = opts
else:
out[key] = val
return out
class NetSpec(object):
"""One VLAN to provision, after the template has been applied to it."""
def __init__(self, where, vlan, name, cidr, extra, tmpl):
self.where = where # 'plan.yaml networks[2]' or 'vlans.csv line 4'
self.vlan = vlan
self.name = name
self.cidr = cidr # ipaddress.IPv4Network
self.extra = extra # free-form fields usable in {placeholders}
self.t = tmpl
self.view = tmpl["view"]
self.network_view = tmpl["network_view"]
self.gateway = None # filled by geometry()
self.range_start = None
self.range_end = None
self.reverse_zones = []
self.rfc2317_parent = None
self.exclusions = []
self.resolved_options = {"network": {}, "range": {}}
self.steps = [] # Step objects, in topological order
# -- placeholders -----------------------------------------------------
def fields(self):
f = {
"vlan": self.vlan,
"name": self.name,
"cidr": str(self.cidr),
"network": str(self.cidr.network_address),
"prefix": self.cidr.prefixlen,
"netmask": str(self.cidr.netmask),
"broadcast": str(self.cidr.broadcast_address),
"gateway": str(self.gateway) if self.gateway else "",
}
f.update(self.extra)
return f
def fmt(self, text):
"""Format a template string. An unknown placeholder is an error at
validation time, not a KeyError in the middle of an apply."""
if text is None:
return None
return str(text).format(**self.fields())
def label(self):
return "VLAN %s %s %s" % (dashed(self.vlan), self.name, self.cidr)
def _as_bool(value, default=False):
if value is None or value == "":
return default
if isinstance(value, bool):
return value
return str(value).strip().lower() in ("yes", "true", "1", "y", "on")
def load_plan(path, csv_path, errors):
"""Read the template file, and the network list from it or from the CSV.
Everything in here runs with no grid and no credentials. The whole input
is checked before the first WAPI call on purpose: failing on the fourth
VLAN with three already provisioned is the worst outcome this script can
have, and it is the one the ordering exists to prevent.
"""
if not os.path.exists(path):
raise SystemExit("template file not found: %s" % path)
with open(path, encoding="utf-8-sig") as fh:
try:
doc = yaml.safe_load(fh) or {}
except yaml.YAMLError as exc:
raise SystemExit("%s is not valid YAML:\n%s" % (path, exc))
if not isinstance(doc, dict):
raise SystemExit("%s must be a YAML mapping with a 'template:' key." % path)
raw_tmpl = doc.get("template") or {}
if not isinstance(raw_tmpl, dict):
raise SystemExit("'template:' in %s must be a mapping." % path)
unknown = sorted(set(raw_tmpl) - TEMPLATE_KEYS)
if unknown:
errors.append((path, "unknown template key(s): %s. Valid keys: %s"
% (", ".join(unknown), ", ".join(sorted(TEMPLATE_KEYS)))))
base = merge_template(TEMPLATE_DEFAULTS, raw_tmpl)
specs = []
if csv_path:
specs = _load_csv(csv_path, base, errors)
if doc.get("networks"):
errors.append((path, "both --csv and a 'networks:' block are present. "
"Pick one: two lists of VLANs in one run is how "
"the wrong one gets provisioned"))
else:
items = doc.get("networks")
if not isinstance(items, list) or not items:
raise SystemExit(
"%s has no 'networks:' list and no --csv was given.\n"
"Either add a networks: block to the template file, or pass the "
"VLANs with --csv." % path)
for n, item in enumerate(items):
where = "%s networks[%d]" % (os.path.basename(path), n)
if not isinstance(item, dict):
errors.append((where, "each entry under networks: must be a mapping"))
continue
over = {k: v for k, v in item.items() if k in TEMPLATE_KEYS}
extra = {k: v for k, v in item.items()
if k not in TEMPLATE_KEYS and k not in ("vlan", "name", "cidr")}
spec = _make_spec(where, item.get("vlan"), item.get("name"),
item.get("cidr"), extra, merge_template(base, over),
errors)
if spec:
specs.append(spec)
return base, specs
def _load_csv(path, base, errors):
"""The CSV carries the VLANs; the template carries everything else. That
split is the point of the script: VLAN 30 at the new site comes out
identical to VLAN 20 at the old one because the same template produced
both."""
if not os.path.exists(path):
raise SystemExit("CSV not found: %s" % path)
specs = []
with open(path, encoding="utf-8-sig", newline="") as fh:
reader = csv.DictReader(fh)
if not reader.fieldnames:
raise SystemExit("%s is empty -- it needs a header row." % path)
headers = [(h or "").strip().lower() for h in reader.fieldnames]
missing = [h for h in ("vlan", "name", "cidr") if h not in headers]
if missing:
raise SystemExit(
"CSV header is missing: %s\n"
"Required columns: vlan,name,cidr. Optional: %s, plus any column "
"you want to use as a {placeholder} in the template."
% (", ".join(missing), ", ".join(CSV_OVERRIDES)))
for n, raw in enumerate(reader, start=2):
clean = {}
for key, val in raw.items():
if key is None:
continue
clean[(key or "").strip().lower()] = (val or "").strip()
if not any(clean.get(k) for k in ("vlan", "name", "cidr")):
continue # a blank line, not an error
over = {}
for key in CSV_OVERRIDES:
if clean.get(key):
over[key] = _as_bool(clean[key], True) if key == "dhcp" else clean[key]
for key in ("reserve_low", "reserve_high"):
if key in over and str(over[key]).isdigit():
over[key] = int(over[key])
extra = {k: v for k, v in clean.items()
if k not in CSV_OVERRIDES and k not in ("vlan", "name", "cidr")}
spec = _make_spec("%s line %d" % (os.path.basename(path), n),
clean.get("vlan"), clean.get("name"),
clean.get("cidr"), extra, merge_template(base, over),
errors)
if spec:
specs.append(spec)
return specs
def _make_spec(where, vlan, name, cidr, extra, tmpl, errors):
if vlan in (None, ""):
errors.append((where, "vlan is required"))
return None
if not str(vlan).isdigit() or not 1 <= int(vlan) <= 4094:
errors.append((where, "vlan '%s' is not a VLAN id between 1 and 4094"
% dashed(vlan)))
return None
if not name:
errors.append((where, "name is required -- it is what the comments and "
"the gateway record are built from"))
return None
if not LABEL_RE.match(str(name)):
errors.append((where, "name '%s' is not usable as a DNS label (letters, "
"digits, '-' and '_', 1-63 characters)" % dashed(name)))
return None
try:
net = ipaddress.ip_network(str(cidr), strict=True)
except ValueError as exc:
errors.append((where, "cidr '%s' is not a network: %s" % (dashed(cidr), exc)))
return None
if net.version != 4:
errors.append((where, "this script provisions IPv4 only; '%s' is IPv6"
% cidr))
return None
return NetSpec(where, int(vlan), str(name), net, extra, tmpl)
# ---------------------------------------------------------------- geometry
def geometry(spec, errors):
"""Work out the gateway, the DHCP range and the reverse zones.
This is where the two mistakes that this script exists for are caught,
and both of them are arithmetic rather than API trouble:
a range that starts at .1 eats the gateway, and
a prefix longer than /24 has no reverse zone you can simply create.
"""
net = spec.cidr
t = spec.t
where = spec.where
for key in ("reserve_low", "reserve_high"):
val = t.get(key)
if isinstance(val, bool) or not isinstance(val, int) or val < 0:
errors.append((where, "%s must be a whole number of addresses, not '%s'"
% (key, dashed(val))))
return
# -- the gateway ------------------------------------------------------
gw = t.get("gateway")
total = net.num_addresses
if total < 4:
errors.append((where, "%s is a /%d -- it has no room for a gateway and a "
"DHCP range. Provision it by hand if you really want "
"the network object" % (net, net.prefixlen)))
return
if gw == "first":
spec.gateway = net.network_address + 1
elif gw == "last":
spec.gateway = net.broadcast_address - 1
elif isinstance(gw, int) and not isinstance(gw, bool):
if not 1 <= gw <= total - 2:
errors.append((where, "gateway offset %d is outside %s" % (gw, net)))
return
spec.gateway = net.network_address + gw
else:
try:
addr = ipaddress.ip_address(str(gw))
except ValueError:
errors.append((where, "gateway '%s' is not 'first', 'last', an offset "
"or an address" % dashed(gw)))
return
if addr not in net:
errors.append((where, "gateway %s is not inside %s" % (addr, net)))
return
if addr in (net.network_address, net.broadcast_address):
errors.append((where, "gateway %s is the network or broadcast address "
"of %s" % (addr, net)))
return
spec.gateway = addr
# -- the reverse zones -------------------------------------------------
spec.reverse_zones, spec.rfc2317_parent = reverse_zones_for(net)
# -- the DHCP range ----------------------------------------------------
if not _as_bool(t.get("dhcp"), True):
return
low, high = t["reserve_low"], t["reserve_high"]
start = net.network_address + 1 + low
end = net.broadcast_address - 1 - high
if int(start) > int(end):
errors.append((where, "nothing is left for a DHCP range in %s: reserve_low "
"%d plus reserve_high %d consumes all %d usable "
"addresses. Lower them, or set dhcp: false"
% (net, low, high, total - 2)))
return
if start <= spec.gateway <= end:
errors.append((where, "the DHCP range %s-%s would hand out the gateway %s. "
"A pool that starts at the first usable address eats "
"the gateway, and the symptom is a duplicate-address "
"complaint on the router, not a DHCP error. Raise "
"reserve_low (or reserve_high for a last-address "
"gateway) past it"
% (start, end, spec.gateway)))
return
spec.range_start, spec.range_end = start, end
# -- explicit exclusions ----------------------------------------------
cleaned = []
for item in t.get("exclusions") or []:
text = str(item).strip()
parts = [p.strip() for p in text.split("-")]
if len(parts) != 2:
errors.append((where, "exclusion '%s' must be written 'first-last'"
% dashed(text)))
continue
try:
a, b = ipaddress.ip_address(parts[0]), ipaddress.ip_address(parts[1])
except ValueError:
errors.append((where, "exclusion '%s' is not a pair of addresses" % text))
continue
if int(a) > int(b):
errors.append((where, "exclusion '%s' runs backwards" % text))
continue
if not (start <= a <= end and start <= b <= end):
errors.append((where, "exclusion %s-%s is outside the DHCP range %s-%s. "
"NIOS rejects it, and an exclusion outside the "
"pool excludes nothing anyway"
% (a, b, start, end)))
continue
cleaned.append({"start_address": str(a), "end_address": str(b),
"comment": "reserved by template"})
spec.exclusions = cleaned
def validate_options(spec, errors):
"""Option names, option values, and the one that costs money later: the
same option defined on the network AND on the range with the same value."""
t = spec.t
scopes = t.get("options") or {}
if not isinstance(scopes, dict):
errors.append((spec.where, "options: must be a mapping with 'network' "
"and/or 'range' under it"))
return
bad_scope = sorted(set(scopes) - {"network", "range"})
if bad_scope:
errors.append((spec.where, "options has unknown scope(s) %s -- only "
"'network' and 'range' exist"
% ", ".join(bad_scope)))
resolved = {}
for scope in ("network", "range"):
resolved[scope] = {}
for name, value in (scopes.get(scope) or {}).items():
if name not in DHCP_OPTIONS:
errors.append((spec.where, "DHCP option '%s' is not one this script "
"knows. Known: %s"
% (dashed(name), ", ".join(sorted(DHCP_OPTIONS)))))
continue
try:
text = spec.fmt(value)
except KeyError as exc:
errors.append((spec.where, "option %s uses the placeholder %s, "
"which nothing provides" % (name, exc)))
continue
if name in IP_LIST_OPTIONS:
for part in str(text).split(","):
try:
ipaddress.ip_address(part.strip())
except ValueError:
errors.append((spec.where, "option %s: '%s' is not an IPv4 "
"address" % (name, part.strip())))
break
elif name in INT_OPTIONS and not str(text).strip().isdigit():
errors.append((spec.where, "option %s must be a whole number, got "
"'%s'" % (name, text)))
resolved[scope][name] = str(text)
spec.resolved_options = resolved
def validate_strings(spec, errors):
"""Every template string, resolved now rather than mid-apply.
A placeholder nobody provides is a KeyError. Finding it here costs a
second; finding it after the second VLAN has been created costs a change
window.
"""
for key in ("network_comment", "range_comment", "gateway_record"):
try:
spec.fmt(spec.t.get(key))
except (KeyError, IndexError, ValueError) as exc:
errors.append((spec.where, "%s uses a placeholder nothing provides: "
"%s. Available: %s"
% (key, exc, ", ".join(sorted(spec.fields())))))
for name, value in (spec.t.get("extattrs") or {}).items():
try:
spec.fmt(value)
except (KeyError, IndexError, ValueError) as exc:
errors.append((spec.where, "extended attribute %s uses a placeholder "
"nothing provides: %s" % (name, exc)))
def cross_spec_errors(specs):
"""Contradictions BETWEEN the networks in one file. The per-network checks
cannot see these, and they are the ones that half-provision a site."""
errors = []
seen_cidr = {}
seen_vlan = {}
for spec in specs:
key = (spec.network_view, spec.cidr)
if key in seen_cidr:
errors.append((spec.where, "%s in network view '%s' is already on %s"
% (spec.cidr, spec.network_view, seen_cidr[key])))
else:
seen_cidr[key] = spec.where
vkey = (spec.network_view, spec.vlan)
if vkey in seen_vlan:
errors.append((spec.where, "VLAN %d appears twice (also on %s). If two "
"subnets really share a VLAN id, provision "
"them in separate runs so the comments do "
"not collide"
% (spec.vlan, seen_vlan[vkey])))
else:
seen_vlan[vkey] = spec.where
for i, a in enumerate(specs):
for b in specs[i + 1:]:
if a.network_view != b.network_view or a.cidr == b.cidr:
continue
if a.cidr.overlaps(b.cidr):
errors.append((b.where, "%s overlaps %s from %s in the same network "
"view. NIOS will accept the container and "
"the child, which is probably not what this "
"file meant"
% (b.cidr, a.cidr, a.where)))
return errors
# ---------------------------------------------------------------- planning
class Step(object):
"""One WAPI object this run would create or change.
`needs` is the whole reason this class is not a dict. A DHCP range cannot
exist before its network, and a PTR cannot exist before its reverse zone.
The plan is executed in this order and a step whose parent failed is NOT
attempted -- it is reported as not attempted, which is a different fact
from failed.
"""
def __init__(self, spec, key, obj, what, verdict, reason,
payload=None, ref=None, previous=None, needs=None):
self.spec = spec
self.key = key
self.obj = obj
self.what = what
self.verdict = verdict
self.reason = reason
self.payload = payload or {}
self.ref = ref
self.previous = previous
self.needs = needs or []
self.result = None # None | 'ok' | 'failed: ...' | 'not attempted: ...'
self.created_ref = None
def satisfied(self):
"""True when the object this step is responsible for is on the grid
afterwards -- whether this run put it there or found it there."""
if self.verdict == SKIP:
return True
return self.result == "ok"
class Planner(object):
def __init__(self, wapi, args):
self.w = wapi
self.args = args
self.counts = {}
self.warnings = []
def fetch(self):
"""Read the current state once and index it. Asking WAPI 'does this
exist' per object is one round trip per object; reading the lists once
is the difference between a minute and an afternoon on a big grid."""
w = self.w
self.views = [v.get("name") for v in
w.get("view", {"_return_fields": "name,is_default"})]
self.netviews = [v.get("name") for v in
w.get("networkview", {"_return_fields": "name,is_default"})]
self.members = [m.get("host_name") for m in
w.get("member", {"_return_fields": "host_name"})]
self.zones = [z for z in
w.get("zone_auth", {"_return_fields":
"fqdn,view,zone_format,disable"})]
self.networks = w.get("network", {"_return_fields":
"network,network_view,comment,options,extattrs"})
self.ranges = w.get("range", {"_return_fields":
"start_addr,end_addr,network,network_view,member,"
"server_association_type,options,disable"})
self.a = w.get("record:a", {"_return_fields": "name,ipv4addr,view"})
self.ptr = w.get("record:ptr", {"_return_fields":
"ptrdname,ipv4addr,name,view"})
self.net_by_cidr = {}
for n in self.networks:
self.net_by_cidr[(n.get("network_view") or "default",
n.get("network"))] = n
self.ranges_by_net = {}
for r in self.ranges:
self.ranges_by_net.setdefault(
(r.get("network_view") or "default", r.get("network")), []).append(r)
self.zone_by_fqdn = {}
for z in self.zones:
self.zone_by_fqdn[((z.get("view") or "default"),
(z.get("fqdn") or "").lower())] = z
self.a_by_name = {}
for r in self.a:
self.a_by_name.setdefault(
((r.get("view") or "default"),
(r.get("name") or "").lower()), []).append(r)
self.ptr_by_ip = {}
for p in self.ptr:
self.ptr_by_ip.setdefault(
((p.get("view") or "default"), p.get("ipv4addr")), []).append(p)
self.counts = {
"views": len(self.views), "netviews": len(self.netviews),
"zones": len(self.zones), "networks": len(self.networks),
"ranges": len(self.ranges), "members": len(self.members),
}
# -- forward/reverse zone lookup --------------------------------------
def zone_for(self, fqdn, view):
"""Longest authoritative FORWARD zone holding this name, or None.
Longest match, by label. endswith() alone matches 'evilexample.com'
against 'example.com', which is how a check like this quietly stops
working.
"""
best, best_len = None, -1
for z in self.zones:
if (z.get("view") or "default") != view:
continue
name = (z.get("fqdn") or "")
if is_suffix_zone(fqdn, name) and len(name) > best_len:
best, best_len = z, len(name)
return best
# -- per VLAN ---------------------------------------------------------
def plan(self, specs):
for spec in specs:
self._plan_one(spec)
return specs
def _blocked_all(self, spec, reason):
spec.steps.append(Step(spec, "%s/all" % spec.cidr, "network",
str(spec.cidr), BLOCKED, reason))
def _plan_one(self, spec):
if self.netviews and spec.network_view not in self.netviews:
self._blocked_all(spec, "network view '%s' does not exist on this grid "
"(network views here: %s)"
% (spec.network_view, ", ".join(sorted(self.netviews))))
return
if self.views and spec.view not in self.views:
self._blocked_all(spec, "DNS view '%s' does not exist on this grid "
"(DNS views here: %s)"
% (spec.view, ", ".join(sorted(self.views))))
return
net_key = self._plan_network(spec)
self._plan_range(spec, net_key)
zone_keys = self._plan_reverse(spec)
self._plan_gateway_records(spec, zone_keys)
# -- step 1: the network ----------------------------------------------
def _plan_network(self, spec):
key = "network:%s:%s" % (spec.network_view, spec.cidr)
wanted_opts = spec.resolved_options.get("network") or {}
payload = {"network": str(spec.cidr), "network_view": spec.network_view,
"comment": spec.fmt(spec.t.get("network_comment")) or ""}
if wanted_opts:
payload["options"] = [option_payload(n, v)
for n, v in sorted(wanted_opts.items())]
ea = {k: {"value": spec.fmt(v)} for k, v in (spec.t.get("extattrs") or {}).items()}
if ea:
payload["extattrs"] = ea
existing = self.net_by_cidr.get((spec.network_view, str(spec.cidr)))
if not existing:
spec.steps.append(Step(spec, key, "network", str(spec.cidr), CREATE,
"new network in view '%s'" % spec.network_view,
payload))
return key
have = options_as_map(existing.get("options"))
differs = []
for name, val in sorted(wanted_opts.items()):
if have.get(name) != val:
differs.append("option %s: %s -> %s"
% (name, dashed(have.get(name)), val))
if (existing.get("comment") or "") != payload["comment"]:
differs.append("comment '%s' -> '%s'"
% (dashed(existing.get("comment")), payload["comment"]))
if not differs:
spec.steps.append(Step(spec, key, "network", str(spec.cidr), SKIP,
"already exists with the same comment and "
"options", ref=existing.get("_ref")))
return key
# The PUT carries only the mutable fields. 'network' and 'network_view'
# are set at creation and WAPI rejects them on an update -- sending the
# whole create payload back is how an update turns into a 400.
patch = {k: v for k, v in payload.items()
if k in ("comment", "options", "extattrs")}
self._conflict(spec, key, "network", str(spec.cidr), payload,
existing.get("_ref"), "; ".join(differs), patch=patch)
return key
# -- step 2: the DHCP range -------------------------------------------
def _plan_range(self, spec, net_key):
if spec.range_start is None:
if not _as_bool(spec.t.get("dhcp"), True):
spec.steps.append(Step(spec, "range:%s" % spec.cidr, "range",
str(spec.cidr), SKIP,
"dhcp: false -- no range for this network",
needs=[net_key]))
return
key = "range:%s:%s" % (spec.network_view, spec.cidr)
wanted_opts = spec.resolved_options.get("range") or {}
net_opts = spec.resolved_options.get("network") or {}
for name, val in sorted(wanted_opts.items()):
if net_opts.get(name) == val:
self.warnings.append(
(spec.where, "DHCP option %s is set to the same value on the "
"network and on the range. The range inherits it "
"from the network already; defining it twice "
"changes nothing today and becomes a value nobody "
"dares touch in three years. Keep it in one place"
% name))
payload = {"start_addr": str(spec.range_start),
"end_addr": str(spec.range_end),
"network": str(spec.cidr),
"network_view": spec.network_view,
"comment": spec.fmt(spec.t.get("range_comment")) or ""}
if spec.exclusions:
payload["exclude"] = spec.exclusions
if wanted_opts:
payload["options"] = [option_payload(n, v)
for n, v in sorted(wanted_opts.items())]
member = spec.t.get("dhcp_member")
if member:
if self.members and member not in self.members:
spec.steps.append(Step(spec, key, "range", "%s-%s"
% (spec.range_start, spec.range_end),
BLOCKED,
"dhcp_member '%s' is not a member of this "
"grid (members: %s)"
% (member, ", ".join(sorted(
m for m in self.members if m))),
needs=[net_key]))
return
payload["member"] = {"_struct": "dhcpmember", "name": member}
payload["server_association_type"] = "MEMBER"
else:
msg = ("the range has no dhcp_member. NIOS creates it happily and it "
"serves nothing: a range with no member or failover "
"association is a pool no DHCP server owns. The symptom is "
"clients with no address and no log line anywhere")
if self.args.require_dhcp_member:
spec.steps.append(Step(spec, key, "range", "%s-%s"
% (spec.range_start, spec.range_end),
BLOCKED, msg + " (--require-dhcp-member)",
needs=[net_key]))
return
self.warnings.append((spec.where, msg))
what = "%s-%s" % (spec.range_start, spec.range_end)
existing = None
for r in self.ranges_by_net.get((spec.network_view, str(spec.cidr))) or []:
existing = r
break
if not existing:
spec.steps.append(Step(spec, key, "range", what, CREATE,
"new DHCP range in %s" % spec.cidr,
payload, needs=[net_key]))
return
if (existing.get("start_addr") == str(spec.range_start)
and existing.get("end_addr") == str(spec.range_end)):
spec.steps.append(Step(spec, key, "range", what, SKIP,
"a range with exactly these bounds already "
"exists", ref=existing.get("_ref"),
needs=[net_key]))
return
patch = {k: v for k, v in payload.items()
if k in ("start_addr", "end_addr", "comment", "options",
"exclude", "member", "server_association_type")}
self._conflict(spec, key, "range", what, payload, existing.get("_ref"),
"existing range is %s-%s"
% (dashed(existing.get("start_addr")),
dashed(existing.get("end_addr"))),
patch=patch, needs=[net_key])
# -- step 3: the reverse zone -----------------------------------------
def _plan_reverse(self, spec):
"""The step everybody forgets, and the one with a case the API cannot
rescue you from."""
keys = []
if not _as_bool(spec.t.get("create_reverse_zone"), True):
return keys
if spec.rfc2317_parent:
spec.steps.append(Step(
spec, "zone:%s" % spec.cidr, "zone_auth",
"reverse of %s" % spec.cidr, BLOCKED,
"a /%d needs an RFC 2317 classless reverse zone, and this script "
"will not invent one. Whoever owns %s has to delegate the slice "
"with CNAMEs first; only then can the zone be created here. "
"Everything else for this VLAN is unaffected"
% (spec.cidr.prefixlen, spec.rfc2317_parent)))
return keys
for fqdn in spec.reverse_zones:
key = "zone:%s:%s" % (spec.view, fqdn)
existing = self.zone_by_fqdn.get((spec.view, fqdn.lower()))
if existing:
if existing.get("disable"):
spec.steps.append(Step(spec, key, "zone_auth", fqdn, BLOCKED,
"the reverse zone exists in view '%s' "
"but is DISABLED, so it answers nothing"
% spec.view))
continue
spec.steps.append(Step(spec, key, "zone_auth", fqdn, SKIP,
"reverse zone already exists in view '%s'"
% spec.view, ref=existing.get("_ref")))
keys.append(key)
continue
payload = {"fqdn": fqdn, "view": spec.view, "zone_format": "IPV4",
"comment": "reverse for %s" % spec.cidr}
ns_group = spec.t.get("reverse_zone_ns_group")
if ns_group:
payload["ns_group"] = ns_group
else:
self.warnings.append(
(spec.where, "reverse zone %s would be created with no "
"ns_group. NIOS accepts that and the zone has no "
"name servers assigned, so it is never served. "
"Set reverse_zone_ns_group in the template"
% fqdn))
reason = "new reverse zone"
if len(spec.reverse_zones) > 1:
reason = ("new reverse zone -- a /%d needs %d of them, one per /24"
% (spec.cidr.prefixlen, len(spec.reverse_zones)))
spec.steps.append(Step(spec, key, "zone_auth", fqdn, CREATE, reason,
payload))
keys.append(key)
return keys
# -- step 4 and 5: the gateway records --------------------------------
def _plan_gateway_records(self, spec, zone_keys):
pattern = spec.t.get("gateway_record")
if not pattern:
return
name = spec.fmt(pattern)
if not valid_fqdn(name):
spec.steps.append(Step(spec, "a:%s" % name, "record:a", dashed(name),
BLOCKED,
"gateway_record resolved to '%s', which is not a "
"valid FQDN" % dashed(name)))
return
zone = self.zone_for(name, spec.view)
akey = "a:%s:%s" % (spec.view, name.lower())
if not zone:
spec.steps.append(Step(spec, akey, "record:a",
"%s -> %s" % (name, spec.gateway), BLOCKED,
"no authoritative forward zone on this grid holds "
"%s in view '%s'. This script creates reverse "
"zones, never forward ones -- a forward zone is a "
"namespace decision, not a side effect"
% (name, spec.view)))
return
if zone.get("disable"):
spec.steps.append(Step(spec, akey, "record:a",
"%s -> %s" % (name, spec.gateway), BLOCKED,
"the forward zone %s exists in view '%s' but is "
"disabled" % (zone.get("fqdn"), spec.view)))
return
payload = {"name": name, "ipv4addr": str(spec.gateway), "view": spec.view,
"comment": "gateway for VLAN %d" % spec.vlan}
existing = self.a_by_name.get((spec.view, name.lower())) or []
same = [r for r in existing if r.get("ipv4addr") == str(spec.gateway)]
if same:
spec.steps.append(Step(spec, akey, "record:a",
"%s -> %s" % (name, spec.gateway), SKIP,
"already exists with the same address",
ref=same[0].get("_ref")))
elif existing:
self._conflict(spec, akey, "record:a",
"%s -> %s" % (name, spec.gateway),
payload, existing[0].get("_ref"),
"exists pointing at %s"
% ", ".join(sorted(r.get("ipv4addr") or "-"
for r in existing)),
patch={"ipv4addr": str(spec.gateway)})
else:
spec.steps.append(Step(spec, akey, "record:a",
"%s -> %s" % (name, spec.gateway), CREATE,
"gateway record in zone %s" % zone.get("fqdn"),
payload))
# The PTR. Its parent is the reverse zone, which may be a step in THIS
# run -- that is the dependency the apply order exists for.
rev_fqdn = ipaddress.ip_address(spec.gateway).reverse_pointer
owner = None
for fqdn in spec.reverse_zones:
if is_suffix_zone(rev_fqdn, fqdn):
owner = fqdn
break
pkey = "ptr:%s:%s" % (spec.view, spec.gateway)
if not owner:
spec.steps.append(Step(spec, pkey, "record:ptr",
"%s -> %s" % (spec.gateway, name), BLOCKED,
"no reverse zone for %s is being created or "
"exists here%s"
% (spec.gateway,
" (RFC 2317 delegation needed from %s)"
% spec.rfc2317_parent
if spec.rfc2317_parent else "")))
return
zkey = "zone:%s:%s" % (spec.view, owner)
needs = [zkey] if zkey in zone_keys else []
if not needs and not self.zone_by_fqdn.get((spec.view, owner.lower())):
spec.steps.append(Step(spec, pkey, "record:ptr",
"%s -> %s" % (spec.gateway, name), BLOCKED,
"the reverse zone %s is neither on the grid nor "
"being created by this run" % owner))
return
ppayload = {"ptrdname": name, "ipv4addr": str(spec.gateway),
"view": spec.view,
"comment": "gateway for VLAN %d" % spec.vlan}
existing = self.ptr_by_ip.get((spec.view, str(spec.gateway))) or []
same = [p for p in existing
if (p.get("ptrdname") or "").lower() == name.lower()]
if same:
spec.steps.append(Step(spec, pkey, "record:ptr",
"%s -> %s" % (spec.gateway, name), SKIP,
"PTR already points here",
ref=same[0].get("_ref"), needs=needs))
elif existing:
self._conflict(spec, pkey, "record:ptr",
"%s -> %s" % (spec.gateway, name), ppayload,
existing[0].get("_ref"),
"exists pointing at %s"
% ", ".join(sorted(p.get("ptrdname") or "-"
for p in existing)),
patch={"ptrdname": name}, needs=needs)
else:
spec.steps.append(Step(spec, pkey, "record:ptr",
"%s -> %s" % (spec.gateway, name), CREATE,
"reverse zone %s" % owner, ppayload,
needs=needs))
# -- conflicts ---------------------------------------------------------
def _conflict(self, spec, key, obj, what, payload, ref, detail,
patch=None, needs=None):
"""The object exists with DIFFERENT values. This is the normal case on
a second run or a half-finished change, not the exception, and the
policy has to be explicit: skipping silently leaves the template and
the grid disagreeing, updating silently changes production."""
policy = self.args.on_conflict
if policy == "skip":
spec.steps.append(Step(spec, key, obj, what, SKIP,
"CONFLICT: %s -- left alone (--on-conflict skip)"
% detail, ref=ref, needs=needs))
elif policy == "update":
spec.steps.append(Step(spec, key, obj, what, UPDATE,
"CONFLICT: %s" % detail,
patch if patch is not None else payload,
ref=ref, previous=detail, needs=needs))
else:
spec.steps.append(Step(spec, key, obj, what, BLOCKED,
"CONFLICT: %s (--on-conflict abort)" % detail,
ref=ref, needs=needs))
def dedupe(specs):
"""Two planned operations aiming at the SAME WAPI object are a defect.
WAPI accepts the first and answers 400 on the second, and no amount of
per-row validation sees it coming: the two steps are well formed on their
own. The case that actually happens is the reverse zone. Network views are
separate namespaces and DNS views are separate namespaces, and they are
NOT the same thing -- so two VLANs in two different network views can
perfectly well both ask for 30.30.10.in-addr.arpa in the one DNS view
'default'. One zone, two steps, one 400.
"""
first = {}
for spec in specs:
for step in spec.steps:
if step.verdict not in (CREATE, UPDATE):
continue
if step.key in first:
owner = first[step.key]
step.verdict = SKIP
step.reason = ("the same %s object is already planned by %s -- "
"one write, not two" % (step.obj, owner.spec.where))
else:
first[step.key] = step
# ---------------------------------------------------------------- applying
class Rollback(object):
"""One JSON object per line, flushed after every write.
Flushed, not buffered: the run that needs this file most is the one that
died halfway, and a buffered file loses exactly the objects nobody knows
about.
"""
def __init__(self, path):
self.path = path
self.fh = open(path, "w", encoding="utf-8")
self.count = 0
def record(self, step, ref):
entry = {
"op": "created" if step.verdict == CREATE else "updated",
"wapi_object": step.obj,
"ref": ref,
"what": step.what,
"vlan": step.spec.vlan,
"cidr": str(step.spec.cidr),
"dns_view": step.spec.view,
"network_view": step.spec.network_view,
"source": step.spec.where,
"previous_value": step.previous,
"at": datetime.now(timezone.utc).isoformat(),
}
self.fh.write(json.dumps(entry) + "\n")
self.fh.flush()
self.count += 1
def close(self):
self.fh.close()
def apply_plan(wapi, steps, rollback):
"""Execute in topological order, and NEVER attempt a step whose parent did
not happen.
A DHCP range inside a network that was not created is not a failure worth
reporting as one -- it is a step that was not attempted, which is a
different fact. Conflating the two is how a half-finished run reads as a
catastrophe.
"""
done = {}
by_key = {}
for step in steps:
by_key.setdefault(step.key, step)
for step in steps:
if step.verdict in (SKIP, BLOCKED):
done[step.key] = step.satisfied()
continue
missing = [k for k in step.needs if not done.get(k, False)]
if missing:
parent = by_key.get(missing[0])
step.result = ("not attempted: the %s it lives in (%s) did not get "
"created, and this object cannot exist without it"
% (parent.obj if parent else "parent object",
parent.what if parent else missing[0]))
done[step.key] = False
continue
if step.verdict == CREATE:
ok, res = wapi.post(step.obj, step.payload)
else:
ok, res = wapi.put(step.ref, step.payload)
if ok:
step.result = "ok"
step.created_ref = res if isinstance(res, str) else str(res)
rollback.record(step, step.created_ref)
else:
step.result = "failed: %s" % res
done[step.key] = step.satisfied()
return done
# ---------------------------------------------------------------- reporting
RULE = "=" * 74
THIN = "-" * 74
ORDER_LABEL = {
"network": "1 network",
"range": "2 DHCP range",
"zone_auth": "3 reverse zone",
"record:a": "4 gateway A",
"record:ptr": "5 gateway PTR",
}
def header(args, planner):
print(RULE)
print("infoblox-network-provision NIOS / WAPI %s" % args.wapi_version)
print(RULE)
print("Grid : %s%s" % (args.offline_dir or args.host,
" (offline replay)" if args.offline_dir else ""))
print("Template : %s" % args.template)
print("Networks : %s" % (args.csv or "the networks: block of the template"))
print("Mode : %s" % ("APPLY -- this run WRITES to the grid" if args.apply
else "DRY RUN -- nothing will be written"))
print("Conflicts : --on-conflict %s" % args.on_conflict)
c = planner.counts
if c:
print("Grid holds : %d network(s), %d range(s), %d zone(s), %d DNS view(s), "
"%d network view(s)"
% (c["networks"], c["ranges"], c["zones"], c["views"], c["netviews"]))
if args.offline_dir:
print("")
print(" Everything below came from saved JSON in %s, not from a Grid"
% args.offline_dir)
print(" Master. It proves the plan and the arithmetic; it proves nothing")
print(" about how your grid will answer a POST.")
print("")
def report_errors(errors, args):
print("INPUT REJECTED -- %d problem(s), and the whole list at once"
% len(errors))
print(THIN)
for where, msg in errors[:args.limit]:
print(" %-26s %s" % (where, msg))
if len(errors) > args.limit:
print(" ... %d more (raise --limit)" % (len(errors) - args.limit))
print("")
print(" No transport was built, nothing was read from the grid and nothing")
print(" was written. The whole batch is validated first on purpose:")
print(" stopping on the fourth VLAN with three already provisioned is the")
print(" worst outcome this script can have.")
def report_plan(specs, planner, args):
"""Per VLAN, in the order the steps will run. That order IS the content:
it is a dependency, not a preference."""
for spec in specs:
print("%s [%s]" % (spec.label(), spec.where))
print(THIN)
print(" gateway : %s" % dashed(spec.gateway))
if spec.range_start:
low = int(spec.range_start) - int(spec.cidr.network_address) - 1
high = int(spec.cidr.broadcast_address) - int(spec.range_end) - 1
print(" DHCP range : %s - %s (%d addresses)"
% (spec.range_start, spec.range_end,
int(spec.range_end) - int(spec.range_start) + 1))
print(" reserved : %d low (.%s to .%s), %d high -- gateway and "
"statics live here"
% (low, str(spec.cidr.network_address + 1).split(".")[-1],
str(spec.cidr.network_address + low).split(".")[-1], high))
for ex in spec.exclusions:
print(" excluded : %s - %s"
% (ex["start_address"], ex["end_address"]))
else:
print(" DHCP range : - (none planned)")
print(" reverse zone: %s"
% (", ".join(spec.reverse_zones) if spec.reverse_zones
else "- (RFC 2317, see below)" if spec.rfc2317_parent else "-"))
for step in spec.steps:
print(" %-16s %-9s %-14s %s"
% (ORDER_LABEL.get(step.obj, step.obj), step.verdict,
step.obj, step.what))
print(" %s" % step.reason)
print("")
if planner.warnings:
# Grouped by message, not by VLAN. A template-level mistake produces
# one warning per network, and forty copies of the same sentence is
# how a reader learns to skip the section.
grouped = {}
for where, msg in planner.warnings:
grouped.setdefault(msg, []).append(where)
print("WARNINGS -- valid, accepted by NIOS, and probably not what you want")
print(THIN)
for msg in grouped:
where = grouped[msg]
shown = ", ".join(where[:3])
if len(where) > 3:
shown += " and %d more" % (len(where) - 3)
print(" %s" % msg)
print(" affects: %s" % shown)
print("")
steps = [s for spec in specs for s in spec.steps]
by_obj = {}
for step in steps:
by_obj.setdefault(step.obj, {}).setdefault(step.verdict, 0)
by_obj[step.obj][step.verdict] += 1
print("THE PLAN")
print(THIN)
print(" %-16s %8s %8s %8s %8s" % ("WAPI object", "create", "update",
"skip", "blocked"))
totals = {CREATE: 0, UPDATE: 0, SKIP: 0, BLOCKED: 0}
for obj in sorted(by_obj, key=lambda o: ORDER_LABEL.get(o, o)):
b = by_obj[obj]
for v in totals:
totals[v] += b.get(v, 0)
print(" %-16s %8d %8d %8d %8d"
% (obj, b.get(CREATE, 0), b.get(UPDATE, 0), b.get(SKIP, 0),
b.get(BLOCKED, 0)))
print(" %-16s %8d %8d %8d %8d"
% ("TOTAL", totals[CREATE], totals[UPDATE], totals[SKIP],
totals[BLOCKED]))
writes = totals[CREATE] + totals[UPDATE]
print("")
print(" %d VLAN(s), %d object(s) would be written."
% (len(specs), writes))
return writes
def report_apply(steps, rollback, args):
ok = [s for s in steps if s.result == "ok"]
bad = [s for s in steps if s.result and s.result.startswith("failed")]
skipped = [s for s in steps if s.result and s.result.startswith("not attempted")]
print("")
print("APPLY")
print(THIN)
if args.offline_dir:
print(" SIMULATED. --offline-dir has no grid behind it, so every write was")
print(" faked and NOTHING was created. This path exists to exercise the")
print(" plan end to end; it is not a record of a change.")
for step in (bad + skipped)[:args.limit]:
print(" %-14s %s" % (step.obj, step.what))
print(" %s" % step.result)
print(" %d written, %d failed, %d not attempted."
% (len(ok), len(bad), len(skipped)))
if ok:
print(" WHAT EXISTS NOW because of this run:")
for step in ok[:args.limit]:
print(" %-14s %s" % (step.obj, step.what))
if rollback and rollback.count and not args.offline_dir:
print(" Rollback file: %s (%d entry/entries)"
% (rollback.path, rollback.count))
print(" It holds the _ref of everything this run touched, in the order it")
print(" happened. Undo it in REVERSE order -- a reverse zone will not")
print(" delete while a PTR still lives in it.")
# ---------------------------------------------------------------- entry
def parse_args(argv):
p = argparse.ArgumentParser(
description="Provision a new VLAN on an Infoblox NIOS grid as the five "
"objects it really is -- network, DHCP range, DHCP options, "
"reverse zone and gateway records -- over WAPI v2.x. Dry run "
"unless --apply.")
p.add_argument("--template", required=True,
help="YAML template: the provisioning profile, and optionally "
"the networks: list itself")
p.add_argument("--csv", help="CSV of VLANs to provision, instead of the "
"networks: block. Columns vlan,name,cidr")
p.add_argument("--host", help="Grid Master hostname or IP. Needs IB_USER and IB_PASS")
p.add_argument("--wapi-version", default=DEFAULT_WAPI,
help="WAPI version path segment. Default %s" % DEFAULT_WAPI)
p.add_argument("--apply", action="store_true",
help="Actually write. Without it the script only plans")
p.add_argument("--on-conflict", choices=("skip", "update", "abort"),
default="skip",
help="An object that exists with DIFFERENT values: leave it "
"(skip, the default), change it (update), or refuse the "
"step (abort)")
p.add_argument("--max-objects", type=int, default=200,
help="Refuse to apply a plan bigger than this unless "
"--confirm-large is given. Default 200")
p.add_argument("--confirm-large", action="store_true",
help="Acknowledge a plan over --max-objects")
p.add_argument("--require-dhcp-member", action="store_true",
help="Block any range with no dhcp_member instead of warning. "
"The right setting for CI")
p.add_argument("--rollback-out",
help="Where to write the rollback file. Default "
"rollback-<UTC>.jsonl next to the template")
p.add_argument("--validate-only", action="store_true",
help="Check the template and the VLAN list and stop. No grid, "
"no credentials needed")
p.add_argument("--limit", type=int, default=50,
help="Rows printed per section. Default 50")
p.add_argument("--insecure", action="store_true",
help="Skip TLS verification. Only for a certificate you recognise")
p.add_argument("--save-dir",
help="Save every WAPI response here for later offline runs")
p.add_argument("--offline-dir",
help="Replay a --save-dir. No network, no credentials")
args = p.parse_args(argv)
if args.validate_only:
return args
if bool(args.host) == bool(args.offline_dir):
p.error("pass exactly one of --host or --offline-dir (or --validate-only)")
return args
def main(argv=None):
args = parse_args(argv)
# ---- everything offline, before a transport exists at all ----------
errors = []
_, specs = load_plan(args.template, args.csv, errors)
for spec in specs:
# Geometry first: an option value can be written '{gateway}', and the
# gateway is not known until the geometry has been worked out. The
# other order resolved it to an empty string and validated that.
geometry(spec, errors)
validate_options(spec, errors)
validate_strings(spec, errors)
errors.extend(cross_spec_errors(specs))
errors.sort(key=lambda e: (str(e[0]), str(e[1])))
if args.validate_only:
blamed = set(e[0] for e in errors)
print("%s: %d VLAN(s) ready, %d problem(s) across the whole input."
% (args.template,
len([s for s in specs if s.where not in blamed]), len(errors)))
for where, msg in errors[:args.limit]:
print(" %-26s %s" % (where, msg))
if errors:
print("Nothing else was done. Fix the input and run this again.")
return 1
print("The arithmetic is sound: gateways, ranges, exclusions and reverse")
print("zone names all check out. This says NOTHING about whether those")
print("networks, zones or members exist on your grid -- that needs")
print("--host or --offline-dir.")
return 0
if errors:
wapi = None
planner = Planner(None, args)
header(args, planner)
report_errors(errors, args)
return 2
wapi = Wapi(host=args.host, version=args.wapi_version, insecure=args.insecure,
offline_dir=args.offline_dir, save_dir=args.save_dir)
planner = Planner(wapi, args)
planner.fetch()
planner.plan(specs)
dedupe(specs)
header(args, planner)
writes = report_plan(specs, planner, args)
steps = [s for spec in specs for s in spec.steps]
blocked = [s for s in steps if s.verdict == BLOCKED]
conflicts = [s for s in steps if s.verdict == SKIP
and s.reason.startswith("CONFLICT")]
if not args.apply:
print("")
print(" Dry run. Nothing was written. Re-read the plan above, then add")
print(" --apply to the same command to execute exactly it.")
return 1 if (blocked or conflicts) else 0
if writes > args.max_objects and not args.confirm_large:
print("")
print(" REFUSING TO APPLY: %d objects is over --max-objects %d."
% (writes, args.max_objects))
print(" A CSV with one wrong column is how a site gets a second set of")
print(" networks. If %d is really what you meant, add --confirm-large."
% writes)
return 2
if args.offline_dir:
rollback = Rollback(os.devnull)
else:
path = args.rollback_out or os.path.join(
os.path.dirname(os.path.abspath(args.template)),
"rollback-%s.jsonl"
% datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ"))
rollback = Rollback(path)
try:
apply_plan(wapi, steps, rollback)
finally:
rollback.close()
report_apply(steps, rollback, args)
failed = [s for s in steps if s.result and not s.result == "ok"
and s.verdict in (CREATE, UPDATE)]
return 1 if (failed or blocked or conflicts) else 0
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
sys.stderr.write("\ninterrupted\n")
sys.exit(2)
except SystemExit as exc:
# The shared loader raises SystemExit with a sentence, which Python
# would turn into exit code 1 -- the code this script reserves for
# "there are findings". A setup failure is a 2.
if isinstance(exc.code, str):
sys.stderr.write(exc.code + "\n")
sys.exit(2)
raise
except requests.exceptions.SSLError as exc:
sys.stderr.write("TLS verification failed: %s\n(use --insecure only for a "
"certificate you recognise)\n" % exc)
sys.exit(2)
except requests.exceptions.RequestException as exc:
sys.stderr.write("could not reach the Grid Master: %s\n" % exc)
sys.exit(2)
--templatetextrequiredThe YAML template: the provisioning profile, and optionally the networks: list itself
--csvtextThe VLANs to provision, instead of a networks: block. Columns vlan,name,cidr plus any column you want as a {placeholder}
--hosttextGrid Master hostname or IP. Needs IB_USER and IB_PASS in the environment. Mutually exclusive with --offline-dir
--applyboolActually write to the grid. Without it the script only reads, plans and prints. Run it without this first, every time
--on-conflicttextAn object that exists with DIFFERENT values: skip it (the default, conservative), update it with a PUT, or abort the step. The most consequential flag here
--max-objectsnumberRefuse to apply a plan larger than this many objects unless --confirm-large is given. Default 200
--confirm-largeboolAcknowledge a plan over --max-objects. Only after reading the plan table
--require-dhcp-memberboolBlock any DHCP range with no dhcp_member instead of warning. A range no member serves hands out nothing
--rollback-outtextWhere to write the rollback file of created _refs. Default rollback-<UTC>.jsonl beside the template
--validate-onlyboolCheck the template and the VLAN list and stop. No grid, no credentials, no network
--limitnumberRows printed per section. Default 50. Affects printing only, never the plan
--wapi-versiontextWAPI version path segment. Default v2.12
--insecureboolSkip TLS verification. Only for a certificate you recognise
--save-dirtextSave every WAPI response read, so a later run can replay it with no grid
--offline-dirtextReplay a --save-dir. No network and no credentials. With --apply the writes are SIMULATED and labelled as such
What This Solves
A new VLAN is not one object. It is five, and they all have to exist before anyone on that VLAN can get an address and be resolvable: 1 the network 10.20.30.0/24 in a network view 2 the DHCP range the pool inside it, minus the gateway 3 the DHCP options routers, domain name, name servers 4 the reverse zone 30.20.10.in-addr.arpa 5 the gateway records the A record, and its PTR Doing that by hand in SmartConsole is five wizards in a fixed order, and the fourth one is the one that gets forgotten. The symptom arrives weeks later as a monitoring system with no hostnames, or a mail relay rejecting a server with no reverse. This script takes a VLAN, a prefix and a template, and produces the whole set. What it adds over doing it by hand: The order is enforced, because it is a dependency You cannot create a DHCP range inside a network that does not exist, and you cannot create a PTR in a reverse zone that does not exist. The plan is built and executed in that order, and if a step fails the ones that depended on it are reported as NOT ATTEMPTED -- which is a different fact from failed. The arithmetic is checked before anything is written A pool that starts at the first usable address hands out the gateway. A /25 has no reverse zone you are allowed to create on your own. A /23 needs TWO reverse zones, not one. All of that is caught offline. It is repeatable The template holds the judgement -- how many addresses to reserve, which options go where, how the gateway record is named. VLAN 30 at the new site comes out identical to VLAN 20 at the old one because the same template produced both. It tells you what it is about to do, before it does anything Dry run by default. Nothing is written until you add --apply to the same command, and every object it creates is appended to a rollback file.
Is It Safe to Run
Read this part. This script writes. WITHOUT --apply it is strictly read-only. Every call is a GET: it reads the views, the network views, the grid members, the zones, the networks, the ranges and the existing gateway records, works the plan out in memory and prints it. Nothing on the grid changes. That is the default, and it is how you should run it first, every time. WITH --apply it sends POST to create, and PUT only if you explicitly asked for it with --on-conflict update. It never sends DELETE. It cannot remove a network, a range, a zone or a record, and it will not touch any object your template does not name. These are the only objects it reads: view which DNS views exist networkview which network views exist member which grid members could serve DHCP zone_auth which zones this grid is authoritative for network the networks already defined range the DHCP ranges already defined record:a existing forward records record:ptr existing reverse records And the only ones it can write, one call per object: POST network the subnet, with its network-level DHCP options POST range the DHCP pool, with its exclusions and options POST zone_auth the reverse zone, zone_format IPV4 POST record:a the gateway's forward record POST record:ptr the gateway's reverse record PUT on any of the five, ONLY with --on-conflict update Four guards stand between the template and the grid. The dry run by default; the whole batch validated offline before a transport is even built; a plan that refuses to execute when it exceeds --max-objects (200) unless you add --confirm-large; and the rollback file, flushed after every single write. It never creates a FORWARD zone. A forward zone is a namespace decision, not a side effect of adding a VLAN, so if the gateway record has nowhere to live the script says so and creates nothing.
Before You Start
Four things, once. 1. Python 3.9 or newer: python --version 2. The two libraries this script imports: pip install "requests>=2.28" "PyYAML>=6.0" If pip is not found, use "python -m pip install" instead. 3. HTTPS reach to the Grid Master, and an account with the WAPI permission to create networks, ranges, zones and records. Prove the reach by hand first: curl -k -u admin "https://gm.example.com/wapi/v2.12/networkview" If that returns JSON, the script will work. If it returns 401, fix that before going further. 4. A change window, and the answer to one question the script cannot answer for you: which DHCP member or failover association should serve the pool. A range created with no member is accepted by NIOS and serves nothing.
Your First Run
Start with ONE VLAN, in a lab network view if you have one. Three steps. Step 1 - create a file named template.yaml. This is the whole template, and the networks: block at the bottom is your one VLAN: template: gateway: first reserve_low: 10 reserve_high: 5 dhcp_member: dhcp01.corp.example.com reverse_zone_ns_group: default-grid network_comment: "VLAN {vlan} {name} -- {site}" gateway_record: "gw-vlan{vlan}.{domain}" options: network: routers: "{gateway}" domain-name: "{domain}" networks: - vlan: 30 name: users site: mty2 domain: corp.example.com cidr: 10.20.30.0/24 Change the member, the ns group, the domain and the prefix to yours. Step 2 - check the arithmetic on its own. No grid, no credentials, no network at all: python infoblox_network_provision.py --template template.yaml \ --validate-only This catches a prefix with host bits set, a pool that would eat the gateway and an option value that is not an address, in one second. Step 3 - put your credentials in the environment, then look at the plan: Linux or macOS: export IB_USER='admin' read -s IB_PASS; export IB_PASS Windows PowerShell: $env:IB_USER = 'admin' $env:IB_PASS = 'your-password' python infoblox_network_provision.py --template template.yaml \ --host gm.example.com That is the whole first run. It read the grid, printed the five steps it WOULD take, and wrote nothing. Read the plan. When it says what you expect, run the exact same command again with --apply on the end: python infoblox_network_provision.py --template template.yaml \ --host gm.example.com --apply Then widen. Move the VLAN list into a CSV, keep the template, and run without --apply again: python infoblox_network_provision.py --template template.yaml \ --csv vlans.csv --host gm.example.com
What the Output Means
A dry run of two VLANs. The second one is a /25, which is where the reverse zone stops being automatable: $ python infoblox_network_provision.py --template template.yaml \ --csv vlans.csv --host gm.example.com ========================================================================= infoblox-network-provision NIOS / WAPI v2.12 ========================================================================= Grid : gm.example.com Template : template.yaml Networks : vlans.csv Mode : DRY RUN -- nothing will be written Conflicts : --on-conflict skip Grid holds : 2 network(s), 1 range(s), 3 zone(s), 2 DNS view(s), 2 netw... VLAN 30 users 10.20.30.0/24 [vlans.csv line 2] ------------------------------------------------------------------------- gateway : 10.20.30.1 DHCP range : 10.20.30.11 - 10.20.30.249 (239 addresses) reserved : 10 low (.1 to .10), 5 high -- gateway and statics live here reverse zone: 30.20.10.in-addr.arpa 1 network CREATE network 10.20.30.0/24 new network in view 'default' 2 DHCP range CREATE range 10.20.30.11-10.20.30.249 new DHCP range in 10.20.30.0/24 3 reverse zone CREATE zone_auth 30.20.10.in-addr.arpa new reverse zone 4 gateway A CREATE record:a gw-vlan30.corp.example.com gateway record in zone corp.example.com 5 gateway PTR CREATE record:ptr 10.20.30.1 -> gw-vlan30... reverse zone 30.20.10.in-addr.arpa VLAN 32 guest 10.20.32.128/25 [vlans.csv line 3] ------------------------------------------------------------------------- gateway : 10.20.32.129 DHCP range : 10.20.32.139 - 10.20.32.249 (111 addresses) reserved : 10 low (.129 to .138), 5 high -- gateway and statics live reverse zone: - (RFC 2317, see below) 1 network CREATE network 10.20.32.128/25 new network in view 'default' 2 DHCP range CREATE range 10.20.32.139-10.20.32.249 new DHCP range in 10.20.32.128/25 3 reverse zone BLOCKED zone_auth reverse of 10.20.32.128/25 a /25 needs an RFC 2317 classless reverse zone, and this script will not invent one. Whoever owns 32.20.10.in-addr.arpa has to delegate the slice with CNAMEs first 4 gateway A CREATE record:a gw-vlan32.corp.example.com gateway record in zone corp.example.com 5 gateway PTR BLOCKED record:ptr 10.20.32.129 -> gw-vlan32... no reverse zone for 10.20.32.129 is being created or exists here THE PLAN ------------------------------------------------------------------------- WAPI object create update skip blocked network 2 0 0 0 range 2 0 0 0 zone_auth 1 0 0 1 record:a 2 0 0 0 record:ptr 1 0 0 1 TOTAL 8 0 0 2 2 VLAN(s), 8 object(s) would be written. Dry run. Nothing was written. Re-read the plan above, then add --apply to the same command to execute exactly it. Reading it: The numbered steps are the execution order 1 network, 2 range, 3 reverse zone, 4 gateway A, 5 gateway PTR. They are numbered because the order is a dependency and not a preference. See the next section. The four verdicts CREATE the object does not exist and will be made. UPDATE it exists with different values and will be CHANGED. This only ever appears with --on-conflict update. SKIP nothing will happen. Either it already exists with exactly the values you asked for -- which is what makes re-running this safe -- or it is a conflict being left alone, or another VLAN in the same run already plans that exact object. BLOCKED it will not be attempted, and the reason says why. A blocked step does not stop the others: VLAN 32 above still gets its network, its range and its forward record. The gateway and reserved lines are the ones to read twice They say which addresses the pool will NOT hand out. If the reserved count is too small, DHCP will hand out your gateway or your static infrastructure addresses, and the symptom is a duplicate-address complaint rather than a DHCP error. THE PLAN table is the blast radius One row per WAPI object type, and the TOTAL line is the number of calls --apply would make. Read it before anything else. If the number surprises you, your CSV is not what you think it is. WARNINGS are things NIOS accepts and you probably did not mean A range with no DHCP member. A reverse zone with no name server group. The same DHCP option set to the same value on the network and on the range. None of them is an error; all of them are how a grid ends up with configuration nobody can explain later. With --apply the output gains an APPLY block: how many objects were written, which failed and with which WAPI message, which were NOT ATTEMPTED because their parent did not get created, a list of what exists now because of this run, and the path of the rollback file.
Exit Codes for Cron and CI
0 nothing needs a human. A dry run whose plan is entirely CREATE or SKIP-because-identical, or an --apply run where every write succeeded and nothing was blocked 1 something needs a human: steps blocked, conflicts left alone, a write that failed, or a step not attempted. Also returned by every dry run that found something to say, so a pipeline never treats a plan as a result 2 the run could not proceed: the template or the CSV was rejected, credentials are missing, the grid was unreachable, the plan exceeded --max-objects without --confirm-large, or you pressed Ctrl-C A CI job that fails the merge when the proposed VLAN file cannot be applied cleanly, and never writes anything: python infoblox_network_provision.py --template template.yaml \ --csv vlans.csv --host gm.example.com \ --on-conflict abort --require-dhcp-member Note that 1 means "read the output", not "disaster". It is deliberately the same code for "I would change something" and "I could not", because both need the same thing: a person.
The Five Objects, and Why the Order Is a Dependency
This is the part that makes the script worth running rather than clicking, and none of it is about this script -- it is how NIOS models a subnet. The range lives inside the network, so the network goes first WAPI will not create a range in a network that does not exist, and the error it returns does not say that clearly. Same for the PTR and its reverse zone. A failure halfway through is the normal case, not the exception So the script never pretends otherwise. Steps run in order; if one fails, the steps that depend on it are marked NOT ATTEMPTED, the independent ones still run, and the APPLY block prints WHAT EXISTS NOW because of this run. That list plus the rollback file is what makes a half-finished change recoverable instead of a mystery. The DNS half and the IPAM half are independent of each other If the network POST fails, the reverse zone and the gateway record are still created -- they do not live inside the network object. That is correct, and it is why the APPLY block lists what happened per object rather than per VLAN. Two steps aiming at the SAME object are a defect, and they are invisible WAPI accepts the first and answers 400 on the second, and no row-level validation sees it: both steps are well formed on their own. The case that actually happens is the reverse zone -- two VLANs with the same prefix in two different NETWORK views still share one reverse zone in the one DNS view. The script detects it and demotes the second step to SKIP, saying which VLAN already plans it.
The Reverse Zone, and the One Case Nobody Can Automate
Step 3 is the step that gets forgotten in the GUI, and it has three cases. Confusing them is the actual difficulty. A /24 is one zone 10.20.30.0/24 needs 30.20.10.in-addr.arpa, and that is all. A /23 or shorter needs one zone PER /24 inside it A /23 needs two. A /22 needs four. Creating one and assuming it covers the prefix leaves half the addresses with no reverse at all, and the half that works is the half you tested. The script computes the list and says how many. A /25 or longer has no zone you are allowed to create Reverse delegation is classless below /24 -- RFC 2317 -- and the owner of the parent /24 has to delegate your slice with CNAME records before a zone for it can exist. The script detects this, names the parent zone, BLOCKS that one step and continues with the other four. It does not try the POST and let WAPI return something unhelpful, and it does not quietly skip it either. A zone that exists but is DISABLED is not a zone that works It is reported as blocked, with the view it was found in. A disabled zone answers nothing, so creating PTRs in it would be worse than useless. A reverse zone with no name server group is served by nobody NIOS accepts it. It appears in SmartConsole. It never answers a query. Set reverse_zone_ns_group in the template; without it you get a warning per zone and a zone you will have to fix by hand.
The Range Is Not the Network, and Options Inherit
A pool that starts at the first usable address eats the gateway This is the most common self-inflicted DHCP outage there is, and it does not announce itself: the range is valid, NIOS is happy, and one day a client is handed .1. reserve_low is how many addresses after the network address stay out of the pool -- the gateway, the HSRP or VRRP addresses, the switch management addresses, the printers somebody configured statically. reserve_high does the same at the top, which is where a last-address gateway lives. The script refuses any template where the pool would contain the gateway, and prints the reserved block so you can check it against reality rather than against the template. Exclusions are holes inside the pool, not the edges of it Use reserve_low and reserve_high for the edges, and exclusions for a block in the middle that something else owns. An exclusion outside the pool excludes nothing, and NIOS rejects it -- the script catches that offline. An option set on the network is inherited by every range inside it So decide where each option belongs, once. Things that are true of the subnet go on the network: routers, domain-name, broadcast-address. Things that are true of the pool go on the range: typically dhcp-lease-time, sometimes different name servers for a guest pool. Setting the same option to the same value in both places changes nothing today and leaves a duplicate that nobody dares remove in three years. The script warns about exactly that case. Some options have a use flag, and most do not NIOS shows a handful of options separately and gives them a "use" flag: routers, domain-name-servers, domain-name, broadcast-address and dhcp-lease-time. For those, the value is ignored unless the flag is set, so the script sets it. For every other option the field is meaningless and is not sent. A range with no DHCP member is a pool nobody serves NIOS creates it without complaint. No member, no failover association, no leases, and nothing in any log to tell you why. Set dhcp_member in the template; pass --require-dhcp-member to turn the warning into a blocked step, which is what you want in CI.
Undoing a Run
There is no undo in DDI, and this script does not pretend otherwise. What it gives you is the list. With --apply it writes a rollback file -- JSON Lines, one object per line, flushed after every single write, next to the template unless you point --rollback-out somewhere else: {"op":"created","wapi_object":"range","ref":"range/ZG5zLmRoY3Bf...: 10.20.30.11/10.20.30.249/default","what":"10.20.30.11-10.20.30.249", "vlan":30,"cidr":"10.20.30.0/24","dns_view":"default", "network_view":"default","source":"vlans.csv line 2", "previous_value":null,"at":"2026-10-22T18:04:11+00:00"} Flushed rather than buffered, because the run that most needs this file is the one that died halfway through. Undo it in REVERSE order. That is not a style preference: a reverse zone will not delete while a PTR still lives in it, and a network will not delete while it still holds a range. tac rollback-20261022T180411Z.jsonl | while read -r line; do ref=$(python -c "import sys,json;print(json.loads(sys.argv[1])['ref'])" "$line") curl -k -u "$IB_USER" -X DELETE "https://gm.example.com/wapi/v2.12/$ref" done Read that loop before running it: it deletes. For an entry whose op is "updated", previous_value holds what the object said before, and restoring it is a PUT rather than a DELETE. Keep the file. It is the only record of what a run touched, and it is far more useful than a screenshot of a terminal.
The Template File
One YAML file with a template: block, and either a networks: list in the same file or a separate CSV passed with --csv. The template is the judgement you want repeated; the list is the VLANs you want it applied to. template: gateway: first # first | last | an offset | an address reserve_low: 10 # addresses after the network address kept reserve_high: 5 # addresses before the broadcast kept dhcp: true # false provisions the network with no range dhcp_member: dhcp01.corp.example.com reverse_zone_ns_group: default-grid create_reverse_zone: true view: default # the DNS view network_view: default # the NETWORK view -- a different thing network_comment: "VLAN {vlan} {name} -- {site}" range_comment: "VLAN {vlan} {name} DHCP pool" gateway_record: "gw-vlan{vlan}.{domain}" extattrs: Site: "{site}" exclusions: - 10.20.30.100-10.20.30.110 options: network: routers: "{gateway}" domain-name: "{domain}" range: domain-name-servers: "10.20.1.53,10.20.1.54" dhcp-lease-time: "86400" networks: - vlan: 30 name: users site: mty2 domain: corp.example.com cidr: 10.20.30.0/24 The same VLANs as a CSV, which is the form that scales: vlan,name,cidr,site,domain,network_view 30,users,10.20.30.0/24,mty2,corp.example.com, 31,voice,10.20.31.0/24,mty2,corp.example.com, 32,guest,10.20.32.0/24,mty2,corp.example.com,dmz-nv Required on every network, from either source vlan 1-4094. Used in comments and in the gateway record name. name a DNS label: letters, digits, '-' and '_'. It is what the comments and the record names are built from. cidr the prefix, with no host bits set. IPv4 only. Anything else is a placeholder you can use in the template site, domain, room, ticket -- any extra YAML key or CSV column becomes {site}, {domain}, {room}, {ticket} in a comment, a record name, an extended attribute or an option value. {vlan}, {name}, {cidr}, {network}, {prefix}, {netmask}, {broadcast} and {gateway} are always available. A placeholder nothing provides is reported at validation time, not halfway through an apply. A CSV column may override the template per network gateway, reserve_low, reserve_high, dhcp, dhcp_member, view and network_view. Everything else lives in the template, which is the point: the exceptions are visible precisely because they are few. DNS views and network views are NOT the same thing A network view is an IPAM namespace -- overlapping address space. A DNS view is a resolution namespace. WAPI will not tell you which DNS view goes with which network view, because the mapping is a local convention and nothing on the grid records it. The script validates that both exist and uses what you give it; it will not guess, and a network created in the wrong network view or a zone in the wrong DNS view is valid, silent and wrong. Both are checked before anything else Every network in the batch is validated first: the prefix, the gateway, the pool arithmetic, the exclusions, the option names and values, every placeholder, duplicate prefixes, duplicate VLAN ids and prefixes that overlap each other. Every problem is reported at once -- a user who fixes one line per run gives up before the file is clean.
Credentials
Read from the environment. Never from the template, and never from a command-line argument. Both of those are deliberate. A template file gets mailed around and committed to git. A command-line argument is visible to every user on the machine in "ps auxww" while the script runs, and it lands in your shell history afterwards. IB_USER Infoblox username required IB_PASS password required Linux or macOS -- read -s keeps it out of the history file: export IB_USER='admin' read -s IB_PASS; export IB_PASS Windows PowerShell: $env:IB_USER = 'admin' $env:IB_PASS = 'your-password' Windows Command Prompt: set IB_USER=admin set IB_PASS=your-password These last only for the current terminal session. With --offline-dir or --validate-only neither variable is needed at all, because there is no grid to authenticate to. Use an account scoped to what it will provision. This script can create networks, ranges and zones; the permission model is what decides where.
Every Flag
--template PATH REQUIRED. The YAML template, and optionally the networks: list itself. --csv PATH The VLANs to provision, instead of a networks: block. Columns vlan,name,cidr plus anything you want as a placeholder. --host NAME Grid Master. Needs IB_USER and IB_PASS. Exactly one of --host or --offline-dir, unless you pass --validate-only. --apply Actually write. Without it the script only reads and plans. Run it without this first, always. --on-conflict POLICY skip (default), update or abort, for an object that exists with DIFFERENT values. The most consequential flag here: update sends a PUT and changes what is live today. --max-objects N Refuse to apply a plan larger than N objects. Default 200. Five objects per VLAN adds up faster than people expect. --confirm-large Acknowledge a plan over --max-objects. Only ever type this after reading THE PLAN table. --require-dhcp-member Block any range with no dhcp_member instead of warning about it. The right setting for CI. --rollback-out PATH Where to write the rollback file. Default is rollback-<UTC timestamp>.jsonl beside the template. Only written with --apply. --validate-only Check the template and the VLAN list and stop. No grid, no credentials, no network. The fastest way to clean up a file. --limit N Rows printed per section. Default 50. It only affects printing, never the plan. --wapi-version V WAPI path segment. Default v2.12. Lower it if your NIOS is older. --insecure Skip TLS verification. Only for a Grid Master certificate you recognise. --save-dir PATH Save every WAPI response read, so a later run can replay it with no grid. Useful for handing somebody the exact data a plan came from. --offline-dir PATH Replay a --save-dir. No network, no credentials. With --apply the writes are SIMULATED and clearly labelled -- nothing is created, because there is nothing to create it on. The header says so on every run, so a saved transcript cannot be mistaken for a record of a change.
When Something Fails
"IB_USER and IB_PASS must be set in the environment" Usually you opened a new terminal. Set them again -- see Credentials. "WAPI rejected IB_USER/IB_PASS (401)" Wrong credentials, or the account is locked out. Check by hand: curl -k -u admin "https://gm.example.com/wapi/v2.12/networkview" "TLS verification failed" The Grid Master certificate is not trusted by this machine. Add the CA to your trust store, or pass --insecure if you recognise it. "cidr '10.20.31.7/24' is not a network: has host bits set" Somebody wrote an interface address where a prefix belongs. 10.20.31.0/24 is the network; 10.20.31.7/24 is a host inside it. "the DHCP range ... would hand out the gateway" reserve_low is too small for where the gateway is. Raise it past the gateway, or move the gateway with the gateway: key. "nothing is left for a DHCP range in ..." reserve_low plus reserve_high consumes the whole prefix. Lower them, or set dhcp: false if this network genuinely has no pool. "a /25 needs an RFC 2317 classless reverse zone" Not a bug and not something the script can fix. The owner of the parent /24 has to delegate your slice first. Everything else for that VLAN was still planned. "network view 'x' does not exist on this grid" The name is wrong, or you are pointed at the wrong grid. The message lists the network views that do exist. "no authoritative forward zone on this grid holds gw-vlan30..." The forward zone for the gateway record does not exist here. Create the zone first -- this script will not create a forward zone, because that is a namespace decision rather than a side effect. "dhcp_member 'x' is not a member of this grid" The member host name must match exactly what the grid reports. The message lists the members it found. "REFUSING TO APPLY: N objects is over --max-objects" The guard did its job. Read THE PLAN table. If the number is genuinely what you meant, add --confirm-large; if it is not, your CSV has a column you did not intend. "not attempted: the network it lives in ... did not get created" A parent step failed, so this one was never tried. Fix the parent and re-run the same command: the objects that did get created will come back as SKIP, which is what makes re-running safe. Everything comes back as SKIP and you expected creates It is already provisioned with exactly these values, which is the whole point of re-running. Check you are pointed at the network view and the DNS view you think you are. A write failed with "400: ... is not valid" WAPI's own message is printed verbatim after the object. The commonest causes are a permission that covers the view but not the object type, an extended attribute that is required by grid policy and missing from the template, or an ns_group name that does not exist. Everything written before the failure is already in the rollback file.