Skip to content

State Management and Idempotency

Why Idempotency Matters

Ask yourself: "What happens if I run this automation script twice?"

Bad answer: "It breaks things on the second run" Good answer: "It's safe to run as many times as needed"

Idempotent operations are repeatable without side effects.


The Problem: Non-Idempotent Operations

# ❌ NOT IDEMPOTENT
def add_vlan_100(device):
    """Add VLAN 100."""
    device.send_command("configure terminal")
    device.send_command("vlan 100")
    device.send_command("name DATA")
    device.send_command("exit")
    device.send_command("end")

# Run 1: ✓ VLAN 100 created
# Run 2: ✗ ERROR - VLAN 100 already exists!
# Run 3-5: ✗ ERROR - VLAN 100 already exists!

Non-idempotent operations are unreliable:

  • Fail on retry attempts
  • Can't be used in scheduled jobs (will fail eventually)
  • Cause automation distrust

The Solution: Idempotent Operations

# ✅ IDEMPOTENT
def ensure_vlan_100_exists(device):
    """Ensure VLAN 100 exists (safe to call repeatedly)."""
    # Check if VLAN already exists
    output = device.send_command("show vlan id 100")

    if "VLAN0100" in output:
        print("VLAN 100 already exists, skipping creation")
        return True

    # Only create if it doesn't exist
    device.send_command("configure terminal")
    device.send_command("vlan 100")
    device.send_command("name DATA")
    device.send_command("exit")
    device.send_command("end")

    print("VLAN 100 created")
    return True

# Run 1: ✓ VLAN 100 created
# Run 2: ✓ VLAN 100 already exists (skipped, safe)
# Run 3-5: ✓ VLAN 100 already exists (skipped, safe)

Pattern 1: Check-Before-Change

The Implementation

# src/idempotent_operations.py
from dataclasses import dataclass
from enum import Enum

class OperationStatus(Enum):
    """Status of an idempotent operation."""
    CREATED = "created"
    ALREADY_EXISTS = "already_exists"
    UPDATED = "updated"
    UNCHANGED = "unchanged"
    FAILED = "failed"

@dataclass
class OperationResult:
    """Result of an idempotent operation."""
    status: OperationStatus
    message: str
    details: dict = None

class IdempotentOperations:
    """Idempotent network operations (safe to repeat)."""

    @staticmethod
    def ensure_vlan_exists(device, vlan_id: int, vlan_name: str) -> OperationResult:
        """
        Ensure VLAN exists with given name (idempotent).

        Safe to call multiple times—won't duplicate VLAN.
        """
        # Check if VLAN already exists
        output = device.send_command(f"show vlan id {vlan_id}")

        if f"VLAN{vlan_id:04d}" in output:
            # VLAN exists, verify name matches
            if vlan_name in output:
                return OperationResult(
                    status=OperationStatus.UNCHANGED,
                    message=f"VLAN {vlan_id} exists with correct name",
                    details={"vlan_id": vlan_id, "vlan_name": vlan_name}
                )
            else:
                # VLAN exists but name differs, update it
                device.send_command("configure terminal")
                device.send_command(f"vlan {vlan_id}")
                device.send_command(f"name {vlan_name}")
                device.send_command("exit")
                device.send_command("end")

                return OperationResult(
                    status=OperationStatus.UPDATED,
                    message=f"VLAN {vlan_id} name updated to {vlan_name}",
                    details={"vlan_id": vlan_id, "vlan_name": vlan_name}
                )

        # VLAN doesn't exist, create it
        device.send_command("configure terminal")
        device.send_command(f"vlan {vlan_id}")
        device.send_command(f"name {vlan_name}")
        device.send_command("exit")
        device.send_command("end")

        return OperationResult(
            status=OperationStatus.CREATED,
            message=f"VLAN {vlan_id} created with name {vlan_name}",
            details={"vlan_id": vlan_id, "vlan_name": vlan_name}
        )

    @staticmethod
    def ensure_interface_config(device, interface: str, config_lines: list) -> OperationResult:
        """
        Ensure interface has specific configuration (idempotent).

        Args:
            device: Netmiko device
            interface: Interface name (e.g., "Gi0/0/1")
            config_lines: List of config commands to apply
        """
        # Get current interface configuration
        current_config = device.send_command(f"show running-config interface {interface}")

        # Check if all config lines already exist
        all_exist = all(line in current_config for line in config_lines)

        if all_exist:
            return OperationResult(
                status=OperationStatus.UNCHANGED,
                message=f"Interface {interface} already has required configuration",
                details={"interface": interface, "lines": config_lines}
            )

        # Apply missing configuration
        device.send_command("configure terminal")
        device.send_command(f"interface {interface}")

        for config_line in config_lines:
            device.send_command(config_line)

        device.send_command("exit")
        device.send_command("end")

        return OperationResult(
            status=OperationStatus.UPDATED,
            message=f"Interface {interface} configuration applied",
            details={"interface": interface, "lines": config_lines}
        )

    @staticmethod
    def ensure_bgp_neighbor(device, asn: int, neighbor_ip: str, neighbor_asn: int) -> OperationResult:
        """Ensure BGP neighbor is configured (idempotent)."""
        # Check if neighbor already exists
        output = device.send_command("show ip bgp summary")

        if neighbor_ip in output:
            return OperationResult(
                status=OperationStatus.UNCHANGED,
                message=f"BGP neighbor {neighbor_ip} already configured",
                details={"neighbor_ip": neighbor_ip}
            )

        # Configure BGP neighbor
        device.send_command("configure terminal")
        device.send_command(f"router bgp {asn}")
        device.send_command(f"neighbor {neighbor_ip} remote-as {neighbor_asn}")
        device.send_command("exit")
        device.send_command("end")

        return OperationResult(
            status=OperationStatus.CREATED,
            message=f"BGP neighbor {neighbor_ip} configured",
            details={"neighbor_ip": neighbor_ip, "neighbor_asn": neighbor_asn}
        )

Usage Example

from netmiko import ConnectHandler
from idempotent_operations import IdempotentOperations, OperationStatus

device = ConnectHandler(
    device_type="cisco_ios",
    host="10.0.0.1",
    username="admin",
    password="password"
)

ops = IdempotentOperations()

# Run 1: Creates VLAN 100
result = ops.ensure_vlan_exists(device, 100, "DATA")
print(f"{result.status.value}: {result.message}")
# Output: created: VLAN 100 created with name DATA

# Run 2: Detects VLAN 100 already exists
result = ops.ensure_vlan_exists(device, 100, "DATA")
print(f"{result.status.value}: {result.message}")
# Output: unchanged: VLAN 100 exists with correct name

# Run 3: Safe to call again and again
result = ops.ensure_vlan_exists(device, 100, "DATA")
print(f"{result.status.value}: {result.message}")
# Output: unchanged: VLAN 100 exists with correct name

device.disconnect()

Pattern 2: State-Driven Configuration

The Problem

Imperative code (commands in order) doesn't guarantee desired state:

1
2
3
4
# ❌ IMPERATIVE - Order-dependent
device.send_command("interface Gi0/0/1")
device.send_command("ip address 10.0.0.1 255.255.255.0")
# If someone manually removed the IP later, this script doesn't fix it

The Solution: Declarative State

# ✅ DECLARATIVE - Desired state always applied
desired_state = {
    "interfaces": {
        "Gi0/0/1": {
            "ip": "10.0.0.1",
            "netmask": "255.255.255.0",
            "enabled": True
        }
    }
}

# Ensure device matches desired state (idempotent)
ensure_device_state(device, desired_state)

Implementation

# src/state_driver.py
class StateDrivenConfigurator:
    """Apply desired state to device (idempotent)."""

    def __init__(self, device):
        self.device = device

    def get_current_state(self) -> dict:
        """Retrieve current device state."""
        return {
            "interfaces": self._get_interfaces(),
            "vlans": self._get_vlans(),
            "bgp": self._get_bgp_state(),
        }

    def _get_interfaces(self) -> dict:
        """Get current interface configuration."""
        output = self.device.send_command("show ip interface brief")
        interfaces = {}

        for line in output.split('\n'):
            if 'Gi' in line or 'Et' in line:
                parts = line.split()
                interface = parts[0]
                ip = parts[1] if len(parts) > 1 else None
                status = parts[4] if len(parts) > 4 else None

                interfaces[interface] = {
                    "ip": ip,
                    "status": status
                }

        return interfaces

    def _get_vlans(self) -> dict:
        """Get current VLAN configuration."""
        output = self.device.send_command("show vlan brief")
        vlans = {}

        for line in output.split('\n'):
            if line.strip() and 'VLAN' in line:
                parts = line.split()
                vlan_id = parts[0]
                vlan_name = parts[1] if len(parts) > 1 else ""

                vlans[vlan_id] = {"name": vlan_name}

        return vlans

    def _get_bgp_state(self) -> dict:
        """Get current BGP state."""
        output = self.device.send_command("show ip bgp summary")

        # Simple parsing for neighbor count
        neighbor_count = output.count("Established")

        return {"neighbors_established": neighbor_count}

    def ensure_interfaces(self, desired_interfaces: dict):
        """Ensure interface configuration matches desired state."""
        current = self.get_current_state()["interfaces"]

        for interface, desired_config in desired_interfaces.items():
            current_config = current.get(interface, {})

            # Check if interface configuration matches
            if current_config.get("ip") != desired_config.get("ip"):
                print(f"Updating {interface} IP...")
                self.device.send_command("configure terminal")
                self.device.send_command(f"interface {interface}")
                self.device.send_command(
                    f"ip address {desired_config['ip']} {desired_config['netmask']}"
                )
                self.device.send_command("exit")
                self.device.send_command("end")

    def ensure_vlans(self, desired_vlans: dict):
        """Ensure VLAN configuration matches desired state."""
        current = self.get_current_state()["vlans"]

        for vlan_id, vlan_config in desired_vlans.items():
            if vlan_id not in current:
                print(f"Creating VLAN {vlan_id}...")
                self.device.send_command("configure terminal")
                self.device.send_command(f"vlan {vlan_id}")
                self.device.send_command(f"name {vlan_config['name']}")
                self.device.send_command("exit")
                self.device.send_command("end")

    def apply_desired_state(self, desired_state: dict):
        """Apply all desired state (idempotent)."""
        print("Applying desired state...")

        if "interfaces" in desired_state:
            self.ensure_interfaces(desired_state["interfaces"])

        if "vlans" in desired_state:
            self.ensure_vlans(desired_state["vlans"])

        print("✓ Desired state applied")

Usage

device = ConnectHandler(
    device_type="cisco_ios",
    host="10.0.0.1", 
    username="admin",
    password="password"
)

configurator = StateDrivenConfigurator(device)

desired_state = {
    "interfaces": {
        "Gi0/0/1": {
            "ip": "10.0.0.1",
            "netmask": "255.255.255.0"
        }
    },
    "vlans": {
        "100": {"name": "DATA"},
        "101": {"name": "VOICE"}
    }
}

# Run 1: Applies all configuration
configurator.apply_desired_state(desired_state)

# Run 2: Checks state, skips what's already correct
configurator.apply_desired_state(desired_state)

# Run 3-N: Safe to run anytime
configurator.apply_desired_state(desired_state)

Pattern 3: Nornir with Idempotent Tasks

# src/nornir_idempotent_tasks.py
from nornir import InitNornir
from nornir.core.task import Task, Result
from idempotent_operations import IdempotentOperations

def ensure_vlans(task: Task) -> Result:
    """Nornir task: Ensure VLANs exist (idempotent)."""
    device = task.host.get_connection("netmiko")
    ops = IdempotentOperations()

    # Get VLAN configuration from inventory or task data
    vlans = task.host.get("vlans", {})

    results = []
    for vlan_id, vlan_config in vlans.items():
        result = ops.ensure_vlan_exists(
            device,
            vlan_id,
            vlan_config["name"]
        )
        results.append(result)

    return Result(
        host=task.host,
        result={
            "vlans": [
                {
                    "vlan_id": r.details["vlan_id"],
                    "status": r.status.value,
                    "message": r.message
                }
                for r in results
            ]
        }
    )

# Usage
nr = InitNornir(config_file="config.yaml")
results = nr.run(task=ensure_vlans)

# Results show what changed
for hostname, result in results.items():
    for vlan in result[0].result["vlans"]:
        print(f"{hostname}: VLAN {vlan['vlan_id']} - {vlan['status']}")

Best Practices

1. Separate Checking from Doing

# ✅ GOOD
def ensure_vlan_exists(device, vlan_id):
    # 1. Check
    if vlan_exists(device, vlan_id):
        return "already_exists"

    # 2. Create only if needed
    create_vlan(device, vlan_id)
    return "created"

# ❌ BAD - Assumes creation is needed
def create_vlan(device, vlan_id):
    device.send_command(f"vlan {vlan_id}")  # Fails if exists!

2. Return Operation Status

1
2
3
4
5
6
7
8
9
# ✅ GOOD - Caller knows what happened
status = ensure_vlan_exists(device, 100)
if status == "created":
    print("New VLAN created")
elif status == "unchanged":
    print("VLAN already existed")

# ❌ BAD - Caller doesn't know
ensure_vlan_exists(device, 100)  # Did something happen?

3. Make Operations Small and Focused

1
2
3
4
5
6
7
# ✅ GOOD - Single responsibility
def ensure_vlan_exists(device, vlan_id, vlan_name):
    # Only concerned with VLAN existence

# ❌ BAD - Too much responsibility
def configure_vlan_and_interfaces_and_bgp(device, vlan_id):
    # Does too many things, hard to reason about

4. Test Idempotency Explicitly

def test_operation_is_idempotent(mock_device):
    """Verify running operation twice is safe."""

    # Run 1
    result1 = ensure_vlan_exists(mock_device, 100, "DATA")
    assert result1.status == OperationStatus.CREATED

    # Run 2 - should be safe
    result2 = ensure_vlan_exists(mock_device, 100, "DATA")
    assert result2.status == OperationStatus.UNCHANGED

    # Run 3 - still safe
    result3 = ensure_vlan_exists(mock_device, 100, "DATA")
    assert result3.status == OperationStatus.UNCHANGED

Summary

Concept Definition
Idempotent Safe to run multiple times, always produces same result
Check-before-change Verify state before making changes
Declarative "What state do we want?" not "What commands run?"
Status reporting Tell caller what actually happened

Idempotent automation is reliable, repeatable, and trustworthy.


Next Steps