# Introduction to Network Automation: Build Your First Backup Script with Python for a multi-vendor environment - Part 2

**Welcome back to Part 2 of our network automation series.** In Part 1, we explored the fundamentals of Netmiko, including connecting to a device and executing commands. Now, we will apply those skills.

In this post, we will develop a practical configuration backup script to automate a common task for network engineers: backing up device configurations across multiple network devices. Instead of manually SSH-ing into each router or switch, you will have a Python script that performs this task automatically and saves timestamped backups.

**What you'll learn:**

*   Efficiently managing multiple device connections
    
*   Implementing error handling for practical scenarios
    
*   Organizing and timestamping backup files
    

**What you'll need:**

*   A basic understanding of Netmiko (as covered in Part 1)
    
*   Python 3.x installed
    
*   Access to network devices (whether physical, virtual, or via Containerlab)
    
*   A text editor or IDE
    

By the end of this tutorial, you'll have a working automation script that you can adapt for your own network environment. Let's get started

# Building Your First Backup Script

## Version 2: Multi-Vendor Support

Now, let's extend this to support Nokia, Cisco, and Juniper devices.

```python
from netmiko import ConnectHandler
from datetime import datetime
import os
import sys

# Device inventory - add your devices here
devices = [
    {
        'device_type': 'alcatel_sros',
        'host': '172.20.20.13',
        'username': 'username',
        'password': 'password',
        'timeout': 60,
        'vendor': 'nokia'
    },
    {
        'device_type': 'cisco_xr',  # Use 'cisco_xe' for IOS-XE, 'cisco_xr' for IOS-XR
        'host': '172.20.20.15',
        'username': 'username',
        'password': 'password',
        'timeout': 60,
        'vendor': 'cisco'
    },
    {
        'device_type': 'juniper_junos',
        'host': '172.20.20.16',
        'username': 'username',
        'password': 'password',
        'timeout': 60,
        'vendor': 'juniper'
    }
]

# Vendor-specific commands
VENDOR_COMMANDS = {
    'nokia': {
        'pager': 'environment no more',
        'config': 'admin display-config',
        'prompt': r'#'
    },
    'cisco': {
        'pager': 'terminal length 0',
        'config': 'show running-config',
        'prompt': r'#'
    },
    'juniper': {
        'pager': 'set cli screen-length 0',
        'config': 'show configuration',
        'prompt': r'[>#]'
    }
}

# Create backup directory if it doesn't exist
backup_dir = 'backups'
if not os.path.exists(backup_dir):
    os.makedirs(backup_dir)
    print(f"Created backup directory: {backup_dir}\n")

def validate_config(config_text, vendor):
    """Validate that configuration was retrieved successfully"""
    if not config_text or len(config_text) < 100:
        return False, "Configuration appears empty or too short"

    # Vendor-specific validation
    validation_keywords = {
        'nokia': ['configure', 'system'],
        'cisco': ['version', 'interface'],
        'juniper': ['system', 'interfaces']
    }

    keywords = validation_keywords.get(vendor, [])
    config_lower = config_text.lower()

    if not any(keyword in config_lower for keyword in keywords):
        return False, f"Configuration doesn't appear to be valid {vendor.upper()} config"

    return True, "Configuration validated"

def backup_device(device_info):
    """Backup a single device and return status"""
    vendor = device_info.get('vendor', 'unknown')
    host = device_info['host']

    print(f"\n{'='*70}")
    print(f"Processing {vendor.upper()} device: {host}")
    print(f"{'='*70}")

    try:
        # Get vendor-specific commands
        commands = VENDOR_COMMANDS.get(vendor)
        if not commands:
            return False, f"Unknown vendor: {vendor}"

        # Create connection parameters (exclude 'vendor' key)
        connection_params = {k: v for k, v in device_info.items() if k != 'vendor'}

        # Connect to device
        print(f"Connecting to {host}...")
#        connection = ConnectHandler(**device_info)
        connection = ConnectHandler(**connection_params)
        print("✓ Connected successfully")

        # Get hostname for filename
#        hostname = connection.find_prompt().strip('#> ')
#        print(f"✓ Device hostname: {hostname}")

        # Get hostname for filename
        raw_hostname = connection.find_prompt().strip('#> ')

        # Clean hostname - remove invalid characters for filenames
        # For Cisco XR: RP/0/RP0/CPU0:hostname becomes hostname
        if ':' in raw_hostname:
            hostname = raw_hostname.split(':')[-1]  # Get part after last colon
        else:
            hostname = raw_hostname

        # Remove any remaining invalid filename characters
        hostname = hostname.replace('/', '_').replace('\\', '_').replace(':', '_')


        # Set terminal parameters
        print("Setting terminal parameters...")
        connection.send_command(commands['pager'], expect_string=commands['prompt'])

        # Retrieve configuration
        print(f"Retrieving configuration from {hostname}...")
        config = connection.send_command(
            commands['config'],
            expect_string=commands['prompt'],
            delay_factor=2
        )

        # Validate configuration
        is_valid, message = validate_config(config, vendor)
        print(f"✓ {message}")

        if not is_valid:
            print(f"⚠ Warning: {message}")
            connection.disconnect()
            return False, message

        # Create timestamp for filename
        timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')

        # Create vendor subdirectory
        vendor_dir = os.path.join(backup_dir, vendor)
        if not os.path.exists(vendor_dir):
            os.makedirs(vendor_dir)

        # Create filename
        filename = f"{vendor_dir}/{hostname}_{timestamp}.txt"

        # Save configuration to file
        with open(filename, 'w', encoding='utf-8') as f:
            f.write(f"# Backup created: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
            f.write(f"# Device: {hostname} ({host})\n")
            f.write(f"# Vendor: {vendor.upper()}\n")
            f.write(f"# Device Type: {device_info['device_type']}\n")
            f.write(f"# {'='*60}\n\n")
            f.write(config)

        # Verify file was created and has content
        file_size = os.path.getsize(filename)
        print(f"✓ Configuration saved to {filename}")
        print(f"✓ File size: {file_size:,} bytes")

        # Disconnect
        connection.disconnect()
        print("✓ Disconnected successfully")

        return True, filename

    except ConnectionError as e:
        error_msg = f"Connection error: {str(e)}"
        print(f"✗ {error_msg}")
        return False, error_msg

    except Exception as e:
        error_msg = f"Error: {str(e)} (Type: {type(e).__name__})"
        print(f"✗ {error_msg}")
        return False, error_msg

# Main execution
def main():
    """Backup all devices in inventory"""
    print("\n" + "="*70)
    print("MULTI-VENDOR NETWORK DEVICE BACKUP")
    print("="*70)
    print(f"Start time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
    print(f"Total devices: {len(devices)}\n")

    results = {
        'success': [],
        'failed': []
    }

    # Process each device
    for device in devices:
        success, info = backup_device(device)

        if success:
            results['success'].append({
                'host': device['host'],
                'vendor': device.get('vendor', 'unknown'),
                'file': info
            })
        else:
            results['failed'].append({
                'host': device['host'],
                'vendor': device.get('vendor', 'unknown'),
                'error': info
            })

    # Print summary
    print("\n" + "="*70)
    print("BACKUP SUMMARY")
    print("="*70)
    print(f"Successful: {len(results['success'])}/{len(devices)}")
    print(f"Failed: {len(results['failed'])}/{len(devices)}")

    if results['success']:
        print("\n✓ Successful backups:")
        for item in results['success']:
            print(f"  - {item['vendor'].upper()}: {item['host']}")

    if results['failed']:
        print("\n✗ Failed backups:")
        for item in results['failed']:
            print(f"  - {item['vendor'].upper()}: {item['host']}")
            print(f"    Reason: {item['error']}")

    print(f"\nEnd time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
    print("="*70 + "\n")

    # Exit with appropriate code
    sys.exit(0 if not results['failed'] else 1)

if __name__ == "__main__":
    main()
```

## Expected Output

```python
python3 multivendorbackup.py

======================================================================
MULTI-VENDOR NETWORK DEVICE BACKUP
======================================================================
Start time: 2026-01-22 21:14:29
Total devices: 3


======================================================================
Processing NOKIA device: 172.20.20.13
======================================================================
Connecting to 172.20.20.13...
✓ Connected successfully
Setting terminal parameters...
Retrieving configuration from PE1...
✓ Configuration validated
✓ Configuration saved to backups/nokia/PE1_20260122_211432.txt
✓ File size: 4,263 bytes
✓ Disconnected successfully

======================================================================
Processing CISCO device: 172.20.20.15
======================================================================
Connecting to 172.20.20.15...
✓ Connected successfully
Setting terminal parameters...
Retrieving configuration from cisco_xrv9000...
✓ Configuration validated
✓ Configuration saved to backups/cisco/cisco_xrv9000_20260122_211434.txt
✓ File size: 2,529 bytes
✓ Disconnected successfully

======================================================================
Processing JUNIPER device: 172.20.20.16
======================================================================
Connecting to 172.20.20.16...
✓ Connected successfully
Setting terminal parameters...
Retrieving configuration from admin@juniper...
✓ Configuration validated
✓ Configuration saved to backups/juniper/admin@juniper_20260122_211435.txt
✓ File size: 1,114 bytes
✓ Disconnected successfully

======================================================================
BACKUP SUMMARY
======================================================================
Successful: 3/3
Failed: 0/3

✓ Successful backups:
  - NOKIA: 172.20.20.13
  - CISCO: 172.20.20.15
  - JUNIPER: 172.20.20.16

End time: 2026-01-22 21:14:35
======================================================================
```

## Lets analyze the script for a clearer understanding

1.  `from netmiko import ConnectHandler` Firstly we import the `ConnectHandler` from the `netmiko` library used to connect into network devices
    
2.  `from datetime import datetime` We then import `datetime` from the `datetime` library , to be able to work with time and dates
    
3.  `import os` which is an operating system library used for working with files and folders
    
4.  `import sys` used for system-level operations
    
5.  **Device Inventory**: This is where we define all the devices we want to back up. The `devices` variable holds our list of all devices. Inside the list, each device is represented by a dictionary
    
6.  `vendor_commands` A dictionary of dictionaries (nested dictionaries). Each vendor has its own set of commands
    
7.  Create a backup directory if it doesn’t exist. `backup_dir = 'backups'` a variable is created named `backup_dir` . This is the name of the folder where we will store the backups. `os.path.exists(backup_dir)`
    
    Checks if the folder exists and it returns `True` if it exists, `False` if it doesn't
    
8.  The **Validation Function** `def validate_config(config_text, vendor)` checks if `config_text` is empty or None and returns True if it is empty, and False if it has content. The `or` operator means if either condition is true, the entire expression is true. `len(config_text) < 100` Checks if config has fewer than 100 characters. `return` returns two values to the function `false` = validation failed and `"Configuration appears..."` = Error Message .
    
9.  **Vendor-Specific Validation** involves creating a dictionary of keywords we expect to find. Each vendor's configuration should include certain words. If these words are missing, there could be a problem..
    
10.  `def backup_device(device_info)`: This function takes one input, `device_info`, which is a dictionary containing the device details. `vendor = device_info.get('vendor', 'unknown')` uses `.get()` to safely retrieve the vendor. If the 'vendor' key doesn't exist, it uses 'unknown' as the default. `host = device_info['host']` gets the IP address from `device_info`.
     
11.  Create connection parameters using `connection_params = {k: v for k, v in device_info.items() if k != 'vendor'}`. The `device_info.items()` retrieves pairs of (key, value), and `for k, v in ...` loops through each pair. If `k != 'vendor'`, it doesn't include the vendor, and `{k: v ...}` creates a new dictionary. Connect to the device.
     
12.  Retrieve the **hostname** for the **filename** and sanitize it by removing any characters that are not valid for filenames.
     
13.  `connection.send_command(commands['pager'], expect_string=commands['prompt'])` used to turn off pagination
     
14.  The `commands['config']` is used to retrieve the configuration, and `expect_string=commands['prompt']` specifies the prompt to wait for before continuing.
     
15.  `is_valid, message = validate_config(config, vendor)` calls the validation function. If the validation fails, it prints a warning.
     
16.  Create a timestamp and a vendor directory. Use `os.path.join()` to combine folder paths.
     
17.  Create the filename using `filename = f"{vendor_dir}/{hostname}_{timestamp}.txt"` and save the configuration.
     
18.  Verify file creation by checking the file size in bytes with `os.path.getsize(filename)`.
     
19.  Error Handling except ConnectionError as e : Catches only connection-related errors and except Exception as e : Catches any other error
     
20.  The main function `main()` orchestrates the entire backup process. It calls `backup_device()` for each device and collects and displays the results.
     

## How It All Works Together

### The Complete Flow

```plaintext
1. Script starts
├─> Import libraries
├─> Define device list
├─> Define vendor commands
└─> Create backup directory

2. main() function runs
├─> Print header
├─> Create results dictionary
└─> For each device:
├─> Call backup_device()
│ ├─> Connect to device
│ ├─> Get hostname
│ ├─> Disable paging
│ ├─> Get configuration
│ ├─> Validate configuration
│ ├─> Save to file
│ ├─> Disconnect
│ └─> Return success/failure
│
└─> Add to results (success or failed list)

3. Print summary
├─> Show success count
├─> Show failure count
├─> List successful backups
├─> List failed backups
└─> Exit with status code
```

# **Best Practices and Tips**

## Security Considerations

**Avoid hardcoding passwords in scripts**. Consider using one of the following approaches:

*   Environmental variables
    
*   Encrypted vaults (Ansible Vault, HashiCorp Vault)
    
*   Keyring libraries
    
*   Prompts for passwords at runtime
    

Example with environment variables:

```python
devices = 
    {
        'device_type': 'alcatel_sros',
        'host': '172.20.20.13',
        'username': os.environ.get('NETWORK_USERNAME'),
        'password': os.environ.get('NETWORK_PASSWORD'),
  
    }
```

## Error Handling

Always implement proper error handling:

*   Connection timeouts
    
*   Authentication failures
    
*   Device unreachable
    
*   Insufficient privileges
    

## Logging

Comprehensive logging helps troubleshoot issues:

*   Connection attempts
    
*   Successful operations
    
*   Errors with full stack traces
    
*   Execution duration
    

## Backup Retention

Implement a retention policy to manage disk space

# Troubleshooting Common Issues

## Issue 1: "Authentication failed"

Solution : Verify credentials, check if AAA is configured correctly

## Issue 2: "Connection timeout"

Solution : Verify network connectivity, check firewall rules, ensure SSH is enabled

## Issue 3: "Command not recognized"

Solution : Verify the backup command for your device type, some platforms use different commands

## Issue 4: "Permission denied"

Solution : Ensure the user has proper privilege levels (Cisco: privilege 15, Juniper: superuser class)

# Next Steps

Now that you have a functional backup script, consider implementing the following enhancements:

1.  **Schedule automated backups** using cron (Linux) or Task Scheduler (Windows)
    
2.  **Add Git integration** to track configuration changes over time
    
3.  **Implement differential backups** to store only changes
    
4.  **Add email notifications** for backup failures
    
5.  **Extend to more device types** (Palo Alto, Fortinet etc.)
    

# Conclusion

Congratulations on building your first network automation tool. This script has the potential to save you significant time and serves as a foundation for more advanced automation tasks.

In next week's post, we will explore Network APIs and discuss how to manage network devices using modern API-first approaches on Juniper platforms.

# Download the code

All the code from this post is available on GitLab. : [network-automation-week-1-part2](https://gitlab.com/kgosileburu/network-automation-scripts)

# Questions or Feedback?

Connect with me on LinkedIn. I'd love to hear about your automation journey!
