Networks as Software#

For decades, networks were configured by hand: an engineer opened an SSH session to a switch and typed commands. That works for five devices. With hundreds of devices, frequent changes, and several environments, manual configuration is slow, inconsistent, and impossible to audit. Nobody can say with confidence what is configured where, or why. Network automation applies software engineering practice to network configuration, and Infrastructure as Code (IaC) makes the desired state of the network an explicit, versioned, reviewable artefact.

Why Automate#

  • Consistency: every branch switch gets the same configuration, generated from the same template, so there is no drift between devices.
  • Speed: a change across 300 devices takes minutes, not a week of maintenance windows.
  • Auditability: every change is a commit with an author, a reviewer, and a reason.
  • Safety: validation and testing catch mistakes before they reach a device.
  • Recovery: if the network's state lives in a repository, rebuilding a failed device is a redeploy instead of an archaeology project on old backups.

Core Ideas#

  • Declarative vs. imperative. Imperative automation lists steps ("add VLAN 20, then set the interface"). Declarative automation describes the end state ("these VLANs exist, nothing else"), and the tool works out the difference. Terraform, Kubernetes manifests, and NAPALM's replace mode are declarative.
  • Idempotency. Applying the same configuration twice produces no further change. A script that blindly appends ntp server lines every run is not idempotent; a tool that compares first and changes only what differs is.
  • Source of truth. One authoritative data set (a Git repository of YAML files, or an IPAM/DCIM system such as NetBox) describes sites, devices, VLANs, and addresses. Configurations are generated from it, never edited directly on devices.
  • Structured interfaces. Screen-scraping CLI output is fragile. NETCONF and RESTCONF with YANG data models, and gNMI over gRPC, let tools read and write configuration and state as structured data. Where devices only offer a CLI, libraries such as Netmiko handle the SSH session and prompts.

Worked Example: Data, Validation, Template#

Keep the site's facts as data, validate them, and render device configuration from a template. The rendering step refuses to produce anything from invalid data:

import ipaddress
from jinja2 import Environment, StrictUndefined

SITE = {
    "hostname": "br-lon-sw01",
    "vlans": [
        {"id": 10, "name": "USERS",   "subnet": "10.20.10.0/24"},
        {"id": 20, "name": "VOICE",   "subnet": "10.20.20.0/24"},
        {"id": 30, "name": "PRINTERS", "subnet": "10.20.30.0/26"},
    ],
    "ntp_servers": ["10.0.0.123", "10.0.1.123"],
}

TEMPLATE = '''hostname {{ hostname }}
{% for v in vlans %}
vlan {{ v.id }}
 name {{ v.name }}
interface Vlan{{ v.id }}
 ip address {{ v.subnet | gateway }} {{ v.subnet | netmask }}
{% endfor %}
{% for s in ntp_servers %}
ntp server {{ s }}
{% endfor %}'''

def validate(site):
    errors = []
    ids = [v["id"] for v in site["vlans"]]
    if len(ids) != len(set(ids)):
        errors.append("duplicate VLAN id")
    nets = [ipaddress.ip_network(v["subnet"]) for v in site["vlans"]]
    for i, a in enumerate(nets):
        for b in nets[i + 1:]:
            if a.overlaps(b):
                errors.append(f"overlapping subnets {a} and {b}")
    errors += [f"VLAN {v} out of range" for v in ids if not 1 < v < 4095]
    return errors

env = Environment(undefined=StrictUndefined, trim_blocks=True, lstrip_blocks=True)
env.filters["gateway"] = lambda s: str(next(ipaddress.ip_network(s).hosts()))
env.filters["netmask"] = lambda s: str(ipaddress.ip_network(s).netmask)

problems = validate(SITE)
if problems:
    raise SystemExit("refusing to render: " + "; ".join(problems))
print(env.from_string(TEMPLATE).render(**SITE))
hostname br-lon-sw01
vlan 10
 name USERS
interface Vlan10
 ip address 10.20.10.1 255.255.255.0
vlan 20
 name VOICE
interface Vlan20
 ip address 10.20.20.1 255.255.255.0
vlan 30
 name PRINTERS
interface Vlan30
 ip address 10.20.30.1 255.255.255.192
ntp server 10.0.0.123
ntp server 10.0.1.123

The non-obvious lines: StrictUndefined makes a typo such as {{ v.nmae }} raise an error instead of silently rendering an empty string into a device configuration, and the custom filters derive the gateway and netmask from the subnet so the data only has to state each fact once. Change the PRINTERS subnet to 10.20.10.128/26 and the script refuses to render, because that range overlaps the USERS VLAN. Catching that in a pipeline costs nothing; finding it on a live switch costs an outage. The subnetting lesson covers the address arithmetic.

Pushing Changes Safely: Diff Before Commit#

Rendering is half the job. Deploying should always show a diff first and change only what differs. NAPALM provides one API across vendors for exactly this:

import os
from napalm import get_network_driver

def deploy(host, candidate_config, apply=False):
    driver = get_network_driver("ios")          # also "eos", "junos", "nxos", ...
    with driver(host, "netops", os.environ["NET_PASSWORD"]) as device:
        device.load_merge_candidate(config=candidate_config)
        diff = device.compare_config()
        if not diff:
            print(f"{host}: already compliant")
            device.discard_config()
        elif apply:
            device.commit_config()
            print(f"{host}: applied\n{diff}")
        else:
            print(f"{host}: dry run, would change:\n{diff}")
            device.discard_config()

Run it in dry-run mode in CI, post the diffs on the pull request for review, and only run with apply=True after approval. Credentials come from the environment or a secrets manager, never from the repository. Platform-specific requirements apply; the IOS driver, for example, needs SCP enabled on the device to upload the candidate configuration.

Terraform for Cloud Networks#

In the cloud, the network is an API, and IaC is the normal way to manage it. Terraform declares VPCs, subnets, routes, and firewall rules:

resource "aws_vpc" "main" {
  cidr_block = "10.40.0.0/16"
  tags       = { Name = "prod" }
}

resource "aws_subnet" "app_a" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet(aws_vpc.main.cidr_block, 8, 1)   # 10.40.1.0/24
  availability_zone = "eu-west-2a"
}

resource "aws_security_group" "lb" {
  name   = "lb"
  vpc_id = aws_vpc.main.id
}

resource "aws_security_group" "app" {
  name   = "app"
  vpc_id = aws_vpc.main.id
}

# App servers accept HTTPS only from the load balancer's security group
resource "aws_vpc_security_group_ingress_rule" "app_https_from_lb" {
  security_group_id            = aws_security_group.app.id
  referenced_security_group_id = aws_security_group.lb.id
  ip_protocol                  = "tcp"
  from_port                    = 443
  to_port                      = 443
}

The workflow is terraform plan -out=tfplan to see exactly what will be created, changed, or destroyed, review, then terraform apply tfplan to apply that exact plan. Referencing a security group instead of a CIDR range means the rule stays correct as load balancer IPs change.

A Network CI/CD Pipeline#

  1. Lint and validate: terraform fmt -check and terraform validate, schema checks on the source-of-truth data, and the kind of validation shown above.
  2. Policy as code: tools such as Conftest (Open Policy Agent) or Checkov fail the build on rules like "no security group open to 0.0.0.0/0 on port 22" or "every subnet has flow logs enabled".
  3. Analyse and plan: generate device diffs or a Terraform plan and post them for review. Configuration analysis tools such as Batfish can answer "after this change, can the guest VLAN still reach the payment servers?" before anything is deployed.
  4. Test in a lab or virtual topology (containerised network OS images work well for this).
  5. Deploy gradually: canary devices or one site first, then waves.
  6. Verify and roll back: check routing adjacencies, interface state, and synthetic reachability probes after each wave, and roll back automatically if they fail.

How Network Automation Fails#

  • Amplified mistakes. A bad template applied everywhere is an outage everywhere. Canary waves and blast-radius limits (never more than N devices per run) are essential.
  • Locking yourself out. A change to management ACLs or routing can cut the session that would roll it back. Use platform safety nets: Junos commit confirmed 5 reverts automatically unless you confirm within five minutes, and on Cisco IOS scheduling reload in 10 before a risky change (and cancelling it afterwards) achieves a crude version of the same.
  • Drift. Someone fixes an incident by hand at 3 a.m. and the next automated run silently reverts it, or never notices it. Run the dry-run diff on a schedule; terraform plan -detailed-exitcode exits with code 2 when real infrastructure differs from the code, which makes a simple drift alarm.
  • State and secrets. Terraform state files can contain secrets and are the record of what exists. Store them in an encrypted remote backend with locking, never in Git. Keep device credentials in a vault.
  • Screen-scraping fragility. Parsing CLI output with regular expressions breaks on the next software upgrade. Prefer structured APIs, or maintained parsers, wherever available.

Telemetry and Closed Loops#

Automation pairs naturally with monitoring. Streaming telemetry over gNMI, SNMP polling, and flow data show the network's actual state; automation compares it with the intended state in the source of truth. The mature form is closed-loop operation: detect a deviation, propose or apply a corrective change, then verify the result. The network monitoring lesson covers the observability half.

Practice#

Start where risk is low: write a script that uses Netmiko's ConnectHandler and send_command("show running-config") to back up every device's configuration into Git nightly, so each diff becomes a record of change. Then extend the template example to generate one device's full configuration from data, and use NAPALM's dry run to compare it with what is running until the diff is empty.