# SQLite3 ships with Python; no install needed in most environments# If `import sqlite3` fails, install the fallback package:pipinstallpysqlite3-binary
SQLite3 is included in Python by default. If import sqlite3 fails, install pysqlite3-binary.
#!/usr/bin/env python3"""Simple backup (no database, just files)Shows task composition pattern"""fromnornirimportInitNornirfromnornir.core.taskimportTask,Resultfromnornir_netmiko.tasksimportnetmiko_send_commandfromdatetimeimportdatetimeimportos@taskdefget_config(task:Task)->Result:"""Step 1: Get the config"""result=task.run(netmiko_send_command,command_string="show running-config")config=result[0].resultreturnResult(host=task.host,result={'config':config,'timestamp':datetime.now()})@taskdefsave_to_file(task:Task,config_data:dict)->Result:"""Step 2: Save it to disk"""device_name=task.host.nameos.makedirs("configs",exist_ok=True)filename=f"configs/{device_name}_backup.txt"withopen(filename,'w')asf:f.write(config_data['config'])returnResult(host=task.host,result={'filepath':filename,'size':len(config_data['config'])})# Initialize and runnr=InitNornir(config_file="nornir_config.yaml")# Get passwordimportgetpasspwd=getpass.getpass("Password: ")forhostinnr.inventory.hosts.values():host.password=pwd# Run pipelinesprint("\nβ Step 1: Getting configs from all devices...")results1=nr.run(task=get_config)print("β Step 2: Saving to filesystem...")# For each device, save its configfordevice_name,result_objinresults1.items():ifnotresult_obj.failed:config_data=result_obj[device_name].result# Save this device's configsave_task=nr.filter(name=device_name)save_task.run(task=save_to_file,config_data=config_data)print("\nβ Done! Check ./configs/ directory")
Why this matters: By breaking it into separate steps, we can:
# query_backups.pyimportsqlite3fromdatetimeimportdatetime,timedeltaconn=sqlite3.connect("backup.db")cursor=conn.cursor()print("Recent Backups:")cursor.execute(''' SELECT device_name, backup_timestamp, config_size, changed FROM backups WHERE backup_timestamp > datetime('now', '-7 days') ORDER BY backup_timestamp DESC LIMIT 20''')forrowincursor.fetchall():device,timestamp,size,changed=rowstatus="π Changed"ifchangedelse"β Unchanged"print(f"{device:<15}{timestamp:<20}{size:>10,} bytes {status}")print("\n\nCompliance Scores:")cursor.execute(''' SELECT device_name, compliance_score, MAX(check_timestamp) FROM compliance GROUP BY device_name ORDER BY compliance_score DESC''')forrowincursor.fetchall():device,score,timestamp=rowprint(f"{device:<15}{score:>6.1f}/100 ({timestamp})")conn.close()
# You can chain tasks or run them in seriesresult1=task.run(backup_config,...)result2=task.run(save_config,data=result1.result)result3=task.run(detect_changes,config=result1.result['config'])
# Store metadata for historical analysisconn=sqlite3.connect("backup.db")cursor.execute("INSERT INTO backups (device_name, ...) VALUES (...)")conn.commit()
importrequestsdefsend_slack_alert(device_name,message):webhook_url='https://hooks.slack.com/services/YOUR/WEBHOOK/URL'data={'text':f"π¨ {device_name}: {message}"}requests.post(webhook_url,json=data)# Use in compliance_check:ifscore<70:send_slack_alert(device_name,f"Low compliance: {score}/100")
importdatetimedefcleanup_old_backups(days_to_keep=30):conn=sqlite3.connect("backup.db")cursor=conn.cursor()cutoff=datetime.datetime.now()-datetime.timedelta(days=days_to_keep)cursor.execute(''' SELECT filepath FROM backups WHERE backup_timestamp < ? ''',(cutoff.isoformat(),))for(filepath,)incursor.fetchall():ifos.path.exists(filepath):os.remove(filepath)logger.info(f"Deleted old backup: {filepath}")# Also delete old recordscursor.execute(''' DELETE FROM backups WHERE backup_timestamp < ? ''',(cutoff.isoformat(),))conn.commit()conn.close()# Call before backup: cleanup_old_backups(days_to_keep=30)
@taskdefcompliance_check(task:Task,config:str)->Result:try:# Your checksreturnResult(host=task.host,result={...})exceptExceptionase:# Return failed result, don't crashlogger.warning(f"[{task.host.name}] Compliance check failed: {e}")returnResult(host=task.host,result={'score':0,'issues':[str(e)]},failed=True# β Mark as failed but pipeline continues)
Key:failed=True tells Nornir "this device failed but keep going"
defnormalize_config(config):# Remove timestamps and automation markerslines=[]forlineinconfig.split('\n'):# Skip timestamp linesif'last config'inline.lower():continueif'by v'inline.lower():# Skip "generated by version X"continuelines.append(line)return'\n'.join(lines)# In compare function:previous_normalized=normalize_config(previous_config)current_normalized=normalize_config(current_config)changed=(previous_normalized!=current_normalized)
# Instead of:all_results=nr.run(backup_config)process_all(all_results)# β Load everything at once# Use:forbatch_of_devicesinchunked(nr.inventory.hosts,chunk_size=100):filtered=nr.filter(name__in=batch_of_devices)results=filtered.run(backup_config)# Process batch immediately, then free memoryprocess_batch(results)
# Test connection to one devicepython-c"from nornir import InitNornirnr = InitNornir(config_file='nornir_config.yaml')device = nr.filter(name='router1')device.run(my_task)"
importlogging# Setup file loggingfh=logging.FileHandler('backup.log')fh.setLevel(logging.DEBUG)logger=logging.getLogger()logger.addHandler(fh)# Now all logs go to backup.lognr.run(backup_config)print("Logs saved to backup.log")
importosfromdotenvimportload_dotenv# Load from .env file (never commit this!)load_dotenv()device_password=os.environ.get('DEVICE_PASSWORD')device_username=os.environ.get('DEVICE_USERNAME')ifnotdevice_password:raiseValueError("DEVICE_PASSWORD not set in environment")# Update Nornir inventorynr=InitNornir(config_file="nornir_config.yaml")forhostinnr.inventory.hosts.values():host.username=device_usernamehost.password=device_password
importhvac# Connect to Vaultvault_client=hvac.Client(url='https://vault.example.com:8200')# Authenticate (use token, AppRole, or other auth method)vault_client.auth.approle.login(role_id='your_role_id',secret_id='your_secret_id')# Fetch secretsecrets=vault_client.secrets.kv.read_secret_version(path='network/credentials')device_password=secrets['data']['data']['password']# Use in Nornirforhostinnr.inventory.hosts.values():host.password=device_password
deffetch_credentials_for_host(host):"""Fetch host-specific credentials from Vault"""vault_path=host.data.get('vault_path')# ... fetch from Vault using vault_path ...returnusername,password
β Never commit .env files β add to .gitignore
β Rotate credentials regularly β especially if exposed
β Use HTTPS for credential transport β Vault, AWS, or internal APIs
β Log access to secrets β audit who fetched what, when
β Limit secret scope β give each process only what it needs
β Use service accounts β not personal credentials
β Encrypt at rest β database, filesystem, backups
#!/usr/bin/env python3"""Enterprise Config Backup CLIUsage: python backup.py --help"""importargparseimportsysfromnornirimportInitNornirfromtasks.enterprise_backupimportbackup_configdefmain():parser=argparse.ArgumentParser(description="Enterprise Configuration Backup System",epilog="Examples:\n python backup.py --group ios_devices\n python backup.py --filter 'router' --dry-run")# Positional arguments (required)# (none in this example)# Optional argumentsparser.add_argument('--host',help='Backup specific device by name (e.g., "router1")')parser.add_argument('--group',help='Backup entire device group (e.g., "ios_devices")')parser.add_argument('--filter',help='Filter devices by substring in name (e.g., "router" matches "router1", "router2")')parser.add_argument('--dry-run',action='store_true',help='Show what would be backed up without actually backing up')parser.add_argument('--verbose','-v',action='count',default=0,help='Increase verbosity (-v, -vv, -vvv)')parser.add_argument('--workers',type=int,default=10,help='Number of parallel workers (default: 10)')parser.add_argument('--timeout',type=int,default=30,help='Connection timeout in seconds (default: 30)')args=parser.parse_args()try:# Initialize Nornirnr=InitNornir(config_file="nornir_config.yaml")# Apply filtersifargs.host:nr=nr.filter(name=args.host)elifargs.group:nr=nr.filter(group=args.group)elifargs.filter:nr=nr.filter(func=lambdah:args.filter.lower()inh.name.lower())# Show what will runifargs.dry_run:print(f"DRY RUN: Would backup {len(nr.inventory.hosts)} devices:")forhostinnr.inventory.hosts.values():print(f" - {host.name} ({host.hostname})")return0# Confirm with useriflen(nr.inventory.hosts)==0:print("β No devices matched criteria")return1print(f"β Backing up {len(nr.inventory.hosts)} devices...")# Get passwordimportgetpasspassword=getpass.getpass("Device password: ")forhostinnr.inventory.hosts.values():host.password=password# Run backupresults=nr.run(task=backup_config)# Print summaryfailed=sum(1forrinresults.values()ifr.failed)succeeded=len(results)-failedprint(f"\nβ Succeeded: {succeeded}/{len(results)}")iffailed>0:print(f"β Failed: {failed}/{len(results)}")forhost,resultinresults.items():ifresult.failed:print(f" - {host}: {result[host].exception}")return0iffailed==0else1exceptExceptionase:print(f"β Error: {str(e)}")ifargs.verbose>=2:importtracebacktraceback.print_exc()return1if__name__=="__main__":sys.exit(main())
# Show all optionspythonbackup.py--help
# Backup a single devicepythonbackup.py--hostrouter1
# Backup all routerspythonbackup.py--groupios_routers
# Backup devices with "core" in the namepythonbackup.py--filtercore
# Dry run to see what would runpythonbackup.py--groupios_devices--dry-run
# Verbose output for debuggingpythonbackup.py--groupios_devices-vv
# Custom worker countpythonbackup.py--groupios_devices--workers20# Longer timeout for slow devicespythonbackup.py--groupslow_devices--timeout60
apiVersion:batch/v1kind:CronJobmetadata:name:nornir-backupspec:schedule:"02***"# Daily at 2:00 AM UTCjobTemplate:spec:template:spec:containers:-name:nornir-backupimage:nornir-backup:latestenv:-name:DEVICE_PASSWORDvalueFrom:secretKeyRef:name:network-credskey:passwordcommand:["python","backup.py","--group","ios_devices"]restartPolicy:OnFailure
# Use lockfile to prevent concurrent runsimportosLOCK_FILE='/tmp/nornir_backup.lock'ifos.path.exists(LOCK_FILE):print("Backup already running")sys.exit(1)# Create lockopen(LOCK_FILE,'w').close()try:# ... run backup ...finally:os.remove(LOCK_FILE)
β Log everything β You'll need logs when something fails
# Check when last backup ranimportsqlite3fromdatetimeimportdatetimeconn=sqlite3.connect("backup.db")cursor=conn.cursor()cursor.execute(''' SELECT device_name, MAX(backup_timestamp) as last_backup FROM backups GROUP BY device_name ORDER BY last_backup DESC LIMIT 20''')fordevice,last_backupincursor.fetchall():timestamp=datetime.fromisoformat(last_backup)age_hours=(datetime.now()-timestamp).total_seconds()/3600status="β Current"ifage_hours<25else"β Overdue"print(f"{device:<20}{timestamp}{status}")
Not all network devices are directly accessible from your automation server. Many enterprises use jump hosts (bastions) for security. Good news: Nornir + Netmiko fully support this pattern.
fromnornirimportInitNornirfromnornir.core.taskimportTask,Resultfromnornir_netmiko.tasksimportnetmiko_send_command@taskdefbackup_via_bastion(task:Task)->Result:"""Backup config through bastion host"""# Netmiko will use ~/.ssh/config automaticallyresult=task.run(netmiko_send_command,command_string="show running-config")returnResult(host=task.host,result=result[task.host.name].result)# In inventory/hosts.yaml# No special config needed - SSH just uses the proxy!
fromnornir.core.taskimportTask,Result@taskdefbackup_with_proxy(task:Task)->Result:"""Backup through proxy/bastion"""proxy_jump=task.host.data.get('proxy_jump')proxy_user=task.host.data.get('proxy_user')proxy_key=task.host.data.get('proxy_key_file')# Pass proxy info to Netmikoresult=task.run(netmiko_send_command,command_string="show running-config",ssh_config_file=None,# We're handling it manually# Netmiko handles proxy via paramiko)returnResult(host=task.host,result=result[task.host.name].result)
importsubprocessimporttimeimportsocketfromcontextlibimportcontextmanager@contextmanagerdefssh_tunnel(bastion_host,bastion_user,target_host,target_port=22,local_port=None):""" Create SSH tunnel: localhost:local_port -> bastion -> target_host:target_port """iflocal_portisNone:# Find a free local portsock=socket.socket()sock.bind(('',0))local_port=sock.getsockname()[1]sock.close()# Start SSH tunneltunnel_cmd=['ssh','-L',f'{local_port}:{target_host}:{target_port}',f'{bastion_user}@{bastion_host}','sleep 3600'# Keep tunnel open for 1 hour]print(f"Opening tunnel: localhost:{local_port} -> {bastion_host} -> {target_host}:{target_port}")tunnel_process=subprocess.Popen(tunnel_cmd,stdin=subprocess.DEVNULL,stdout=subprocess.DEVNULL,stderr=subprocess.PIPE)# Give tunnel time to establishtime.sleep(2)try:yieldlocal_portfinally:# Close tunneltunnel_process.terminate()tunnel_process.wait(timeout=5)print(f"Closed tunnel: localhost:{local_port}")# Usage in tasks:@taskdefbackup_via_tunnel(task:Task)->Result:"""Backup device via SSH tunnel through bastion"""bastion="bastion.example.com"bastion_user="netadmin"target_device=task.host.hostname# 10.1.1.1withssh_tunnel(bastion,bastion_user,target_device)aslocal_port:# Connect to device through tunnel (localhost:local_port)fromnetmikoimportConnectHandlerdevice={'device_type':'cisco_ios','host':'127.0.0.1','port':local_port,'username':task.host.username,'password':task.host.password,}withConnectHandler(**device)asnet_connect:config=net_connect.send_command('show running-config')returnResult(host=task.host,result={'config':config})
Host bastion
HostName bastion.example.com
User netadmin
ForwardAgent yes # β Enable agent forwarding
Host 10.1.1.*
ProxyJump bastion
User admin
# Bastion forwards your local SSH keys automatically
β οΈ Security Note: Only enable ForwardAgent if you trust the bastion host. Someone with bastion access can use your SSH agent to connect to your devices.
fromnornirimportInitNornirfromnornir.core.taskimportTask,Resultimportparamiko@taskdeftest_bastion_path(task:Task)->Result:"""Verify connectivity through bastion"""device_name=task.host.namehostname=task.host.hostnametry:# Try to connectssh=paramiko.SSHClient()ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())# Uses ~/.ssh/config automaticallyssh.connect(hostname)ssh.close()returnResult(host=task.host,result={'status':'reachable','message':f'Connected through bastion'})exceptExceptionase:returnResult(host=task.host,result={'status':'unreachable','error':str(e)},failed=True)# Usagenr=InitNornir(config_file="nornir_config.yaml")results=nr.run(task=test_bastion_path)fordevice,resultinresults.items():status="β"ifnotresult.failedelse"β"print(f"{device}: {status}{result[device].result['status']}")
Gotcha 1: "Permission denied (publickey)"
- Problem: SSH key not authorized on bastion
- Solution: Add your public key to bastion's ~/.ssh/authorized_keys
Gotcha 2: "Connection timeout" through bastion
- Problem: Bastion can't reach internal device IP
- Solution: Verify device IP is reachable from bastion: ssh -J bastion admin@10.1.1.1
Gotcha 3: Slow connections via bastion
- Problem: Extra network hop = latency
- Solution: Increase Nornir timeout: set connection_timeout: 30 in inventory
Gotcha 4: SSH tunnel ports conflict
- Problem: Multiple devices use same local tunnel port
- Solution: Let system assign random ports (code above does this automatically)
Gotcha 5: Bastion host becomes bottleneck
- Problem: 100 devices Γ connection through same bastion = slow
- Solution: Use multiple bastions or connection pooling