<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[codednetwork]]></title><description><![CDATA[codednetwork]]></description><link>https://codednetwork.com</link><generator>RSS for Node</generator><lastBuildDate>Wed, 19 Aug 2026 14:49:15 GMT</lastBuildDate><atom:link href="https://codednetwork.com/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Event Handling System (EHS) on Nokia SR OS]]></title><description><![CDATA[Automating reactive tasks on a router, capturing diagnostics the moment something breaks, without waiting for a human to notice, is one of the most practical wins network automation offers. On Nokia S]]></description><link>https://codednetwork.com/event-handling-system-ehs-on-nokia-sr-os</link><guid isPermaLink="true">https://codednetwork.com/event-handling-system-ehs-on-nokia-sr-os</guid><category><![CDATA[MD-CLI]]></category><category><![CDATA[transactional]]></category><category><![CDATA[EHS]]></category><category><![CDATA[event-driven-architecture]]></category><dc:creator><![CDATA[Kgosi Leburu]]></dc:creator><pubDate>Tue, 11 Aug 2026 09:04:26 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/3426f48e-66a9-4f16-81b1-b58cec82416e.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Automating reactive tasks on a router, capturing diagnostics the moment something breaks, without waiting for a human to notice, is one of the most practical wins network automation offers. On Nokia SR OS, that capability lives in the Event Handling System (EHS). This article covers why EHS matters and what MD-CLI brings to the table when building EHS actions.</p>
<h1>Event Handling System</h1>
<p>The <strong>Event Handling System (EHS)</strong> is a framework that allows user-defined behavior to be configured on the router. EHS allows you to automate the router response to specific events. When a matching log event (trigger) occurs, EHS can run either a CLI script or a Python 3 application. You can use regular expressions to define flexible trigger conditions.</p>
<div>
<div>💡</div>
<div>The use of Python applications from EHS is supported only in model-driven configuration mode</div>
</div>

<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/601ee475-18db-4a17-b260-a93bb5dae426.png" alt="" style="display:block;margin:0 auto" />

<p><em>Basic EHS Object Handling (MD-CLI)</em></p>
<h2>EHS configuration</h2>
<p>You can configure complex rules to match log events as the trigger for EHS. When a log event is generated in SR OS, it is subject to being discarded if suppression and throttling are configured, before it is evaluated as a trigger for EHS, according to the following</p>
<ul>
<li><p>EHS does not trigger on log events that are suppressed through the configuration.</p>
</li>
<li><p>EHS does not trigger on log events throttled by the logger</p>
</li>
</ul>
<p>When using model-driven configuration mode and the MD-CLI, EHS can trigger a Python application that is executed inside a Python interpreter running on SR OS.</p>
<p>Python applications are not supported in classic configuration mode or mixed configuration mode.</p>
<p>When developing an EHS Python application, the event attributes are passed to the application using the <code>get_event</code> function in the <code>pysros.ehs</code> module</p>
<div>
<div>💡</div>
<div><em>Note: In this article, the main focus will be on MD-CLI</em></div>
</div>

<h4>EHS debounce</h4>
<p>EHS debounce (also called dampening) is the ability to trigger an action (for example, an EHS script) if an event happens (N) times within a specific time window (S)</p>
<blockquote>
<p>For example, when linkDown occurs N times in S seconds, an EHS script is triggered to shut down the port.</p>
</blockquote>
<h4>Executing EHS</h4>
<p>The execution of EHS scripts depends on the <strong>CLI engine</strong> associated with the configuration mode. The EHS script execution engine is based on the configured primary CLI engine.</p>
<p>Use the following command to configure the primary CLI engine.</p>
<pre><code class="language-python">/configure system management-interface cli cli-engine [md-cli]
</code></pre>
<div>
<div>💡</div>
<div><em>Note: If </em><strong><em>cli-engine</em></strong><em> is configured to </em><strong><em>classic-cli</em></strong><em>, the script executes in the classic CLI infrastructure and disregards the configuration mode, even if it is model-driven.</em></div>
</div>

<h1>Why Is EHS Required?</h1>
<p>Traditional monitoring relies on polling. A system checks the router at regular intervals and responds only when it detects a problem.</p>
<p>This delay can cause important diagnostic information to be missed. For example, a flapping card, temporary hardware fault, or brief control-plane issue may recover before the next poll, leaving little or no evidence of what happened.</p>
<p>EHS closes this gap by reacting directly to the router’s internal events and logs as soon as a problem occurs.</p>
<p>Instead of checking the router every few minutes and hoping the evidence is still available, EHS responds immediately when the event happens.</p>
<p>For example, if a line card fails, EHS can automatically generate a tech-support dump at that exact moment. This captures the system state during the failure, including process information, memory, and hardware details.</p>
<p>The key benefit of EHS is <strong>immediate, event-driven diagnostics</strong>.</p>
<h2><em>The Core Advantages</em></h2>
<ul>
<li><p><strong>Zero detection latency</strong> - the action fires as part of the same event pipeline that logs the failure, not on a follow-up poll.</p>
</li>
<li><p><strong>No external dependency for the trigger</strong> - the router doesn't need a collector or NMS in the loop to notice something's wrong; the event system is native to SR OS</p>
</li>
<li><p><strong>Consistent diagnostic capture</strong> - every occurrence of a defined event gets the same automated response, removing "did anyone remember to grab the tech-support before it got busy" from the equation.</p>
</li>
<li><p><strong>Extensible beyond diagnostics</strong> - EHS isn't limited to capturing dumps; it can drive remediation actions, notifications, or config changes tied to specific log events</p>
</li>
</ul>
<h1>What Is MD-CLI?</h1>
<p><strong>MD-CLI (Model-Driven CLI)</strong> is SR OS's YANG-based command-line interface, introduced alongside the model-driven management stack that also powers NETCONF and gNMI on the platform. Unlike the legacy "classic" CLI, every command in MD-CLI maps to a structured YANG data node; configuration, state, and actions are all defined against the same schema the router exposes over its programmatic interfaces.</p>
<p>Practically, this means:</p>
<ol>
<li><p><strong>Structured, predictable command trees</strong> - command hierarchy mirrors the YANG model, so tab-completion and <code>pwc json-instance-path</code> reveal exactly how a command maps to the underlying data model.</p>
</li>
<li><p><strong>Consistency across interfaces</strong> - a config change made via MD-CLI, NETCONF, or gNMI converges on the same model, so there's no drift between <em>"what the CLI shows"</em> and "<em>what an automation tool sees</em>."</p>
</li>
</ol>
<h1>Why MD-CLI for EHS, Specifically?</h1>
<p>This last point turned out to matter more than expected. While building an EHS workflow to auto-capture a tech-support dump on a <code>cardFailure</code> event, the initial approach used a pySROS python-script triggered on-box by EHS, calling admin tech-support via a pySROS management session.</p>
<p>That call failed outright:</p>
<p><em>SrosMgmtError: MINOR: MGMT_AGENT #2007: Operation failed - tech-support not supported from pySROS</em></p>
<p>This wasn't a syntax issue. The SR OS version I was testing on explicitly blocks tech-support generation from being invoked through the pySROS management agent, on-box, regardless of whether it's called via a structured action or the CLI passthrough. The same call worked without issue when run from an external pySROS session connecting into the router, which confirmed the restriction is specific to the on-box Python execution context, not the tech-support action itself.</p>
<p>The practical takeaway: <strong>for on-box</strong>, i<strong>n-the-moment EHS actions</strong>, <strong>MD-CLI script execution is currently the more reliable path on this platform/releas</strong>e. Python-based EHS remains valuable for logic that needs conditionals, state, or external calls, but for firing built-in admin operations like tech-support, MD-CLI's direct execution model avoids a restriction that isn't obvious until you hit it in testing.</p>
<h1>How do we configure it?</h1>
<p>In this use case, we are demonstrating how a tech-support file can be collected if there is a <code>cardFailure</code> event</p>
<h2>1. Create and store the CLI script file (CF/FTP/TFTP)</h2>
<pre><code class="language-python">file 
edit techdump.txt 
/admin tech-support cf3:/test.txt 
</code></pre>
<h2>2. Create script: "configure system script-control script"</h2>
<pre><code class="language-python">/configure system script-control script "techdump" owner "TiMOS CLI" description " Automatic techdumps "
/configure system script-control script "techdump" owner "TiMOS CLI" location "cf3:\techdump.txt"
/configure system script-control script "techdump" owner "TiMOS CLI" admin-state enable
</code></pre>
<h2>3. Configure script-policy : “configure system script-control script-policy”</h2>
<pre><code class="language-python">/configure system script-control script-policy "autodump" owner "TiMOS CLI" admin-state enable
/configure system script-control script-policy "autodump" owner "TiMOS CLI" results "cf3:\"
/configure system script-control script-policy "autodump" owner "TiMOS CLI" script name "techdump"
</code></pre>
<h2>4. Configure log filter: “configure log filter"</h2>
<pre><code class="language-python">
/configure log filter "100" named-entry "1" action forward
/configure log filter "100" named-entry "1" match application eq logger
/configure log filter "100" named-entry "1" match event eq 2011
/configure log filter "100" named-entry "1" match subject eq "CardFailure"
</code></pre>
<h2>5. Configure event-handler: “configure log event-handling handler”</h2>
<pre><code class="language-python">
/configure log event-handling handler "handler-1" admin-state enable
/configure log event-handling handler "handler-1" entry 1 admin-state enable
/configure log event-handling handler "handler-1" entry 1 script-policy name "autodump"
</code></pre>
<h2>6. Configure event-trigger: “configure log event-trigger event”</h2>
<pre><code class="language-python">/configure log event-trigger logger event tmnxTestEvent admin-state enable
/configure log event-trigger logger event tmnxTestEvent entry 10 filter "100"
/configure log event-trigger logger event tmnxTestEvent entry 10 handler "handler-1"
</code></pre>
<h1>How it works</h1>
<p>In this scenario, I'm going to simulate a Card Failure using a <strong>Test Log Event.</strong></p>
<h2>Test log event</h2>
<p>The SR OS provides the <code>tmnxTestEvent</code> event with optional custom text. The test event can be generated with the <code>perform log test-event</code> command. The <code>custom-text</code> command in this context replaces the default message of the event.</p>
<h3>Step 1 - Trigger the event</h3>
<pre><code class="language-python">perform log test-event custom-text "CardFailure"
</code></pre>
<h3>Step 2 - Check logs</h3>
<p>This will indicate if the execution of the script is successful</p>
<pre><code class="language-python">Log 99 log-name 99
==================================================================
Description: Default System Log
Memory Log contents  [size=500   next event=216  (not wrapped)]

215 2026/08/11 03:31:49.231 UTC MAJOR: SYSTEM #2053 Base CLI 'exec'
"The CLI user initiated 'exec' operation to process the commands in the SROS CLI file cf3:\techdump.txt has completed with the result of success"

214 2026/08/11 03:31:37.373 UTC MAJOR: SYSTEM #2052 Base CLI 'exec'
"A CLI user has initiated an 'exec' operation to process the commands in the SROS CLI file cf3:\techdump.txt"

213 2026/08/11 03:31:37.372 UTC MINOR: SYSTEM #2069 Base EHS script
"Ehs handler:"handler-1" with the description: "" was invoked by the cli-user account "admin"."

212 2026/08/11 03:31:37.372 UTC INDETERMINATE: LOGGER #2011 Base Event Test
"CardFailure"
</code></pre>
<h3>Step 3 - Check the run history</h3>
<pre><code class="language-python">/show system script-control script-policy "autodump" 

==================================================================
Script Run History Status Information
------------------------------------------------------------------
Script Run #4
------------------------------------------------------------------
Start time    : 2026/08/11 03:31:37  UTC
End time      : 2026/08/11 03:31:49  UTC
Elapsed time  : 0d 00:00:12             Lifetime      : 0d 00:00:00
State         : terminated              Run exit code : noError
Result time   : 2026/08/11 03:31:49  UTC
Keep history  : 0d 00:55:03
Error time    : never
Source file   : cf3:\techdump.txt
Results file  : cf3:\_20260811-033137-UTC.372706.out
Run exit      : Success
Error         : N/A
Application   : event-script            Auth. user ac*: admin
* indicates that the corresponding row element may have been truncated.
==================================================================
</code></pre>
<h3>Step 4 - Output File</h3>
<pre><code class="language-python">8/11/2026  03:31a              555368 test.txt
05/11/2026  02:36a             6203920 yang.tim
      72 File(s)               16235322 bytes.
      1 Dir(s)            249645916160 bytes free.
[/file "cf3:\"]
</code></pre>
<h1>Conclusion</h1>
<p>The Event Handling System gives Nokia SR OS the ability to react to log events on-box, in real time, closing the gap between a failure occurring and diagnostics being captured. MD-CLI is what makes those reactions dependable, as a native model-driven interface, CLI scripts under <code>script-control</code> execute built-in operations like <code>admin tech-support</code> directly and predictably, tied to the same event pipeline that logs the failure itself.</p>
<p>Together, EHS and MD-CLI turn reactive diagnostic capture from a manual, easily-missed step into something that happens automatically, consistently, every time the trigger condition occurs.</p>
]]></content:encoded></item><item><title><![CDATA[Getting started with pyATS and Genie ]]></title><description><![CDATA[Introduction
Most network automation projects focus on configuration deployment. Engineers use tools such as Python, Ansible, and NetBox to push changes across hundreds of devices.
However, one critic]]></description><link>https://codednetwork.com/getting-started-with-pyats-and-genie</link><guid isPermaLink="true">https://codednetwork.com/getting-started-with-pyats-and-genie</guid><category><![CDATA[pyats]]></category><category><![CDATA[Python]]></category><category><![CDATA[genie]]></category><category><![CDATA[YAML]]></category><dc:creator><![CDATA[Kgosi Leburu]]></dc:creator><pubDate>Mon, 22 Jun 2026 02:51:21 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/a8ad7d0f-05cf-42cc-95e3-2c123d7adb99.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>Introduction</h1>
<p>Most network automation projects focus on configuration deployment. Engineers use tools such as Python, Ansible, and NetBox to push changes across hundreds of devices.</p>
<p>However, one critical question remains:</p>
<p><strong>How do you verify that the network is operating correctly after a change?</strong></p>
<p>This is where <strong>Cisco pyATS</strong> and <strong>Genie</strong> become valuable.</p>
<p><strong>pyATS</strong> (Python Automated Test Systems) is Cisco's open-source network testing and automation framework. At its core, it gives you a structured, programmatic way to connect to network devices, run commands, and validate results instead of manually SSH'ing in and reading the output.</p>
<p><strong>Genie</strong> sits on top of pyATS and adds the parts that make it genuinely useful day-to-day :</p>
<ul>
<li><p><strong>Parsers</strong> — convert raw CLI text into structured Python dictionaries</p>
</li>
<li><p><strong>Models/Ops</strong> — vendor-agnostic representations of network features (BGP, OSPF, interfaces, etc.), built by running several show commands and assembling them into one object</p>
</li>
<li><p><strong>Diff utilities</strong> — compare two snapshots of state and show exactly what changed</p>
</li>
<li><p><strong>A test framework</strong> (<code>aetest</code>) — for writing pass/fail validation scripts</p>
</li>
</ul>
<h1>Why Traditional Validation Is Difficult ?</h1>
<p>After a maintenance window, engineers typically perform manual checks such as:</p>
<ul>
<li><p>Verify interfaces are up</p>
</li>
<li><p>Check routing adjacencies</p>
</li>
<li><p>Confirm BGP sessions</p>
</li>
<li><p>Verify MPLS LSPs</p>
</li>
<li><p>Check CPU and memory utilization</p>
</li>
</ul>
<p>This approach has several challenges:</p>
<ul>
<li><p>Time-consuming</p>
</li>
<li><p>Error-prone</p>
</li>
<li><p>Difficult to scale</p>
</li>
<li><p>No historical comparison</p>
</li>
<li><p>Inconsistent validation procedures</p>
</li>
</ul>
<p>As networks grow, manual validation becomes increasingly unreliable.</p>
<h1>What Problem Does pyATS Solve?</h1>
<p>If you've ever SSH'd into a router, run a show command, looked at the output, copy-pasted it into a spreadsheet and then repeated that for 20 routers .<strong>pyATS</strong> and <strong>Genie</strong> automate exactly that. The result comes back as structured Python data (dictionaries and lists) instead of plain text .</p>
<div>
<div>💡</div>
<div>Analogy: If SSH + CLI is like reading a paper map, Genie parsing is like using GPS coordinates. Same information, but a computer can use it directly.</div>
</div>

<h2>Topology</h2>
<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/30cc0f35-2227-488f-8b98-e8c3a1fbfbf4.jpg" alt="" style="display:block;margin:0 auto" />

<h2>Step 1 - Install pyATS</h2>
<p>Use a virtual environment so this doesn't interfere with other Python projects.</p>
<pre><code class="language-shell">python3 -m venv pyats-env
source pyats-env/bin/activate
pip install pyats[full]
</code></pre>
<p>Check if it worked:</p>
<pre><code class="language-shell">pyats version check
</code></pre>
<p>You should see version numbers for pyATS and pyATS Library (Genie).</p>
<div>
<div>💡</div>
<div>Common issue: if installation fails with a build error, install Python dev headers first — <code>sudo dnf install python3-devel gcc</code> (Rocky/RHEL) or <code>sudo apt install python3-dev gcc</code> (Ubuntu/Debian).</div>
</div>

<h2>Step 2 — Create a Testbed File</h2>
<p>A testbed is a YAML file describing your device basically an inventory file. This is the only "config" pyATS needs.</p>
<p>Create <code>testbed.yaml</code>:</p>
<pre><code class="language-yaml">testbed:
  name: my_first_lab

devices:
  cisco_xrv9000:
    os: iosxr
    type: router
    connections:
      defaults:
        class: unicon.Unicon
      cli:
        protocol: ssh
        ip: 172.20.20.15
        port: 22
    credentials:
      default:
        username: &lt;username&gt;
        password: &lt;password&gt;
</code></pre>
<h2>Step 3 — Connect to a Device</h2>
<p>Your first script just connect and run a command, the "old fashioned" way:</p>
<pre><code class="language-python"># 01_connect.py

from genie.testbed import load

tb = load("testbed.yaml")

router1 = tb.devices["cisco_xrv9000"]
router1.connect()

output = router1.execute("show version")
print(output)

router1.disconnect()
</code></pre>
<p>Execute it :</p>
<pre><code class="language-python">python 01_connect.py
</code></pre>
<p>At this point, output is just a big string same as what you'd see in a terminal. Nothing magic yet. That changes in the next step.</p>
<h2>Step 4 — Your First Parse (The Magic Step)</h2>
<p>This is the single most useful thing Genie does. Instead of <code>execute()</code>, use <code>parse()</code>:</p>
<pre><code class="language-python"># 02_parse.py
from genie.testbed import load

tb = load("testbed.yaml")
router1 = tb.devices["cisco_xrv9000"]
router1.connect()

# The key difference: parse() instead of execute()
parsed = router1.parse("show ip interface brief")

print(parsed)

router1.disconnect()
</code></pre>
<p>Expected Output:</p>
<pre><code class="language-python">2026-06-19 13:23:34,058: %UNICON-INFO: +++ cisco_xrv9000 with via 'cli': executing command 'show ip interface brief' +++
show ip interface brief
Fri Jun 19 01:24:13.557 UTC

Interface                      IP-Address      Status          Protocol Vrf-Name
Loopback0                      10.10.10.3      Up              Up       default
Loopback100                    172.165.11.1    Up              Up       multi-vendor
MgmtEth0/RP0/CPU0/0            10.0.0.15       Up              Up       clab-mgmt
GigabitEthernet0/0/0/0         192.168.1.3     Up              Up       default
GigabitEthernet0/0/0/1         unassigned      Shutdown        Down     default
GigabitEthernet0/0/0/2         10.57.254.3     Up              Up       multi-vendor

# Magic of parsing is instead of receiving a text you get structured data - python dictionary 

RP/0/RP0/CPU0:cisco_xrv9000#
{'interface': {'Loopback0': {'ip_address': '10.10.10.3', 'interface_status': 'Up', 'protocol_status': 'Up', 'vrf_name': 'default'}, 'Loopback100': {'ip_address': '172.165.11.1', 'interface_status': 'Up', 'protocol_status': 'Up', 'vrf_name': 'multi-vendor'}, 'MgmtEth0/RP0/CPU0/0': {'ip_address': '10.0.0.15', 'interface_status': 'Up', 'protocol_status': 'Up', 'vrf_name': 'clab-mgmt'}, 'GigabitEthernet0/0/0/0': {'ip_address': '192.168.1.3', 'interface_status': 'Up', 'protocol_status': 'Up', 'vrf_name': 'default'}, 'GigabitEthernet0/0/0/1': {'ip_address': 'unassigned', 'interface_status': 'Shutdown', 'protocol_status': 'Down', 'vrf_name': 'default'}, 'GigabitEthernet0/0/0/2': {'ip_address': '10.57.254.3', 'interface_status': 'Up', 'protocol_status': 'Up', 'vrf_name':
</code></pre>
<p>Now you can use it like any Python dict:</p>
<pre><code class="language-python">for intf, details in parsed['interface'].items():
    if details['interface_status'] != 'Up':
        print(f"⚠️  {intf} is DOWN")
    else:
        print(f"✅ {intf} is UP — {details['ip_address']}")
</code></pre>
<p>Expected Output:</p>
<pre><code class="language-python">2026-06-19 13:59:52,422: %UNICON-INFO: +++ cisco_xrv9000 with via 'cli': executing command 'show ip interface brief' +++
show ip interface brief
Fri Jun 19 02:00:31.950 UTC

Interface                      IP-Address      Status          Protocol Vrf-Name
Loopback0                      10.10.10.3      Up              Up       default
Loopback100                    172.165.11.1    Up              Up       multi-vendor
MgmtEth0/RP0/CPU0/0            10.0.0.15       Up              Up       clab-mgmt
GigabitEthernet0/0/0/0         192.168.1.3     Up              Up       default
GigabitEthernet0/0/0/1         unassigned      Shutdown        Down     default
GigabitEthernet0/0/0/2         10.57.254.3     Up              Up       multi-vendor

RP/0/RP0/CPU0:cisco_xrv9000#
✅ Loopback0 is UP — 10.10.10.3
✅ Loopback100 is UP — 172.165.11.1
✅ MgmtEth0/RP0/CPU0/0 is UP — 10.0.0.15
✅ GigabitEthernet0/0/0/0 is UP — 192.168.1.3
⚠️  GigabitEthernet0/0/0/1 is DOWN
✅ GigabitEthernet0/0/0/2 is UP — 10.57.254.3
</code></pre>
<p><strong>That's it.</strong> You just turned CLI output into something you can filter, alert on, or feed into another system with no regex.</p>
<h2>Step 5 — Don't Know If a Parser Exists? Check First</h2>
<p>Not every show command has a parser . Two ways to check before you write code:</p>
<h3>Option A — From the command line:</h3>
<pre><code class="language-python">genie parse "show ip interface brief" \
    --testbed-file testbed.yaml \
    --devices cisco_xrv9000
</code></pre>
<p>Expected Output:</p>
<pre><code class="language-python">0%|                                                                                 | 0/1 [00:00&lt;?, ?it/s]{
  "interface": {
    "GigabitEthernet0/0/0/0": {
      "interface_status": "Up",
      "ip_address": "192.168.1.3",
      "protocol_status": "Up",
      "vrf_name": "default"
    },
    "GigabitEthernet0/0/0/1": {
      "interface_status": "Shutdown",
      "ip_address": "unassigned",
      "protocol_status": "Down",
      "vrf_name": "default"
    },
    "GigabitEthernet0/0/0/2": {
      "interface_status": "Up",
      "ip_address": "10.57.254.3",
      "protocol_status": "Up",
      "vrf_name": "multi-vendor"
    },
    "Loopback0": {
      "interface_status": "Up",
      "ip_address": "10.10.10.3",
      "protocol_status": "Up",
      "vrf_name": "default"
    },
    "Loopback100": {
      "interface_status": "Up",
      "ip_address": "172.165.11.1",
      "protocol_status": "Up",
      "vrf_name": "multi-vendor"
    },
    "MgmtEth0/RP0/CPU0/0": {
      "interface_status": "Up",
      "ip_address": "10.0.0.15",
      "protocol_status": "Up",
      "vrf_name": "clab-mgmt"
    }
  }
}
100%|██████████████████████████████████████████████████████████████████████████| 1/1 [00:00&lt;00:00,  1.25it/s]
</code></pre>
<h3>Option B — Browse online :</h3>
<p>Visit the Genie Feature Browser (pubhub.devnetcloud.com) and search for your platform + command.</p>
<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/6b56da77-1119-4fe5-b3d8-bc0351652b31.jpg" alt="" style="display:block;margin:0 auto" />

<h2>Step 6 — learn(): Snapshot an Entire Feature</h2>
<p><code>parse()</code> handles one command. <code>learn()</code> runs several commands and builds a complete model of a feature like OSPF, BGP, or interfaces all in one line.</p>
<pre><code class="language-python">from genie.testbed import load

tb = load("testbed.yaml")
router1 = tb.devices["cisco_xrv9000"]
router1.connect()

# One line — runs multiple show commands behind the scenes
interfaces = router1.learn("interface")

for name, data in interfaces.info.items():
    print(name, "-&gt;", data.get("oper_status"))

router1.disconnect()
</code></pre>
<p>Expected output :</p>
<pre><code class="language-python">2026-06-19 14:09:33,278: %UNICON-INFO: +++ cisco_xrv9000 with via 'cli': executing command 'show vrf all detail' +++
show vrf all detail
Fri Jun 19 02:10:12.809 UTC

VRF clab-mgmt; RD not set; VPN ID not set
VRF mode: Regular
Description Containerlab management VRF (DO NOT DELETE)
Interfaces:
  MgmtEth0/RP0/CPU0/0
Address family IPV4 Unicast
  No import VPN route-target communities
  No export VPN route-target communities
  No import route policy
  No export route policy
Address family IPV6 Unicast
  No import VPN route-target communities
  No export VPN route-target communities
  No import route policy
  No export route policy

2026-06-19 14:09:33,656: %UNICON-INFO: +++ cisco_xrv9000 with via 'cli': executing command 'show interface detail' +++
show interface detail
Fri Jun 19 02:10:13.162 UTC
Loopback0 is up, line protocol is up
  Interface state transitions: 1
  Hardware is Loopback interface(s)
  Internet address is 10.10.10.3/32
  MTU 1500 bytes, BW 0 Kbit
     reliability Unknown, txload Unknown, rxload Unknown
  Encapsulation Loopback,  loopback not set,
  Last link flapped 9w4d
  Last input Unknown, output Unknown
  Last clearing of "show interface" counters Unknown
  Input/output data rate is disabled.

Loopback100 is up, line protocol is up
  Interface state transitions: 1
  Hardware is Loopback interface(s)
  Description: VRF-TEST-LOOPBACK
  Internet address is 172.165.11.1/24
  MTU 1500 bytes, BW 0 Kbit
     reliability Unknown, txload Unknown, rxload Unknown
  Encapsulation Loopback,  loopback not set,
  Last link flapped 9w4d
  Last input Unknown, output Unknown
  Last clearing of "show interface" counters Unknown
  Input/output data rate is disabled.

2026-06-19 14:09:34,238: %UNICON-INFO: +++ cisco_xrv9000 with via 'cli': executing command 'show ethernet tags' +++
show ethernet tags
Fri Jun 19 02:10:13.743 UTC
St:    AD - Administratively Down, Dn - Down, Up - Up
Ly:    L2 - Switched layer 2 service, L3 = Terminated layer 3 service,
Xtra   C - Match on Cos, E  - Match on Ethertype, M - Match on source MAC
-,+:   Ingress rewrite operation; number of tags to pop and push respectively

2026-06-19 14:09:34,607: %UNICON-INFO: +++ cisco_xrv9000 with via 'cli': executing command 'show interfaces accounting' +++
show interfaces accounting
Fri Jun 19 02:10:14.110 UTC
No accounting statistics available for Loopback100

No accounting statistics available for Null0

GigabitEthernet0/0/0/0
  Protocol              Pkts In         Chars In     Pkts Out        Chars Out
  IPV4_UNICAST          3517881        270803858           27             2220
  IPV4_MULTICAST              0                0      1264246         88497220
  MPLS                    51762          3974532      3032839       1444721933
  ARP                       407            24420          407            17094
</code></pre>
<div>
<div>💡</div>
<div><strong>Note</strong> : the above output was truncated. The<code> learn ()</code> function runs multiple commands</div>
</div>

<h2>Step 7 — Before/After Diffs (Change Window Pattern)</h2>
<p>This is the pattern that makes pyATS genuinely useful day-to-day: <strong>snapshot</strong>, <strong>change</strong>, <strong>snapshot again</strong>, <strong>diff</strong>.</p>
<p>There are two ways to do this a <strong>no-code CLI</strong> option (great for quick checks and change windows), and a <strong>Python</strong> option (better when this logic needs to live inside a larger script).</p>
<h3>Option A — No code: genie learn + genie diff</h3>
<p>Collect a baseline before your change:</p>
<pre><code class="language-python"> genie learn bgp \
    --testbed-file testbed.yaml \
    --output baseline
</code></pre>
<p>Expected Output:</p>
<pre><code class="language-python">Learning '['bgp']' on devices '['cisco_xrv9000']'
100%|████████████████████████████████████| 1/1 [00:10&lt;00:00, 10.03s/it]
+=============================================================+
| Genie Learn Summary for device cisco_xrv9000                                 |
+=============================================================+
|  Connected to cisco_xrv9000                                                  |
|  -   Log: baseline/connection_cisco_xrv9000.txt                              |
|-------------------------------------------------------------|
|  Learnt feature 'bgp'                                                        |
|  -  Ops structure:  baseline/bgp_iosxr_cisco_xrv9000_ops.txt                 |
|  -  Device Console: baseline/bgp_iosxr_cisco_xrv9000_console.txt                  |
|=============================================================|
</code></pre>
<div>
<div>💡</div>
<div>Simulated an issue on <strong>PE1</strong> by dropping the BGP session</div>
</div>

<p>Collect the current state:</p>
<pre><code class="language-python"> genie learn bgp \
    --testbed-file testbed.yaml \
    --output output
</code></pre>
<p>Expected Output:</p>
<pre><code class="language-python">Learning '['bgp']' on devices '['cisco_xrv9000']'
100%|███████████████████████████████████████| 1/1 [00:10&lt;00:00, 10.53s/it]
+=============================================================+
| Genie Learn Summary for device cisco_xrv9000                                 |
+=============================================================+
|  Connected to cisco_xrv9000                                                  |
|  -   Log: current/connection_cisco_xrv9000.txt                               |
|-------------------------------------------------------------|
|  Learnt feature 'bgp'                                                        |
|  -  Ops structure:  current/bgp_iosxr_cisco_xrv9000_ops.txt                  |
|  -  Device Console:   current/bgp_iosxr_cisco_xrv9000_console.txt                   |
|=============================================================|
</code></pre>
<p>Now diff the two snapshot folders:</p>
<pre><code class="language-python">genie diff baseline current

1it [00:00, 441.13it/s]
+=============================================================+
| Genie Diff Summary between directories baseline/ and current/                |
+=============================================================+
|  File: bgp_iosxr_cisco_xrv9000_ops.txt                                       |
|   - Diff can be found at ./diff_bgp_iosxr_cisco_xrv9000_ops.txt              |
|-------------------------------------------------------------|
</code></pre>
<p>Expected Output:</p>
<pre><code class="language-python">cat ./diff_bgp_iosxr_cisco_xrv9000_ops.txt

--- baseline/bgp_iosxr_cisco_xrv9000_ops.txt
+++ current/bgp_iosxr_cisco_xrv9000_ops.txt
 info:
  instance:
   default:
    vrf:
     default:
      neighbor:
       10.10.10.1:
-       session_state: established
+       session_state: idle
-       bgp_negotiated_capabilities:
-        four_octets_asn: advertised received
-        route_refresh: advertised received
-        vpnv4_unicast: advertised received
 routes_per_peer:
  instance:
   default:
    vrf:
     default:
      neighbor:
       10.10.10.1:
        address_family:
-        vpnv4 unicast RD 65000:10:
-         routes:
-          10.57.255.0/24:
-           index:
-            1:
-             next_hop: 10.10.10.1
-             origin_codes: i
-             status_codes: *&gt;i
-          172.165.10.0/24:
-           index:
-            1:
-             next_hop: 10.10.10.1
-             origin_codes: i
-             status_codes: *&gt;i
-        vpnv4 unicast RD 65000:30:
-         advertised:
-          10.57.254.0/24:
-           index:
-            1:
-             froms: Local
-             next_hop: 10.10.10.3
-             origin_code: ?
-          172.165.11.0/24:
-           index:
-            1:
-             froms: Local
-             next_hop: 10.10.10.3
-             origin_code: ?
 table:
  instance:
   default:
    vrf:
     default:
      address_family:
-      vpnv4 unicast RD 65000:10:
-       prefixes:
-        10.57.255.0/24:
-         index:
-          1:
-           locprf: 100
-           next_hop: 10.10.10.1
-           origin_codes: i
-           status_codes: *&gt;i
-           weight: 0
-        172.165.10.0/24:
-         index:
-          1:
-           locprf: 100
-           next_hop: 10.10.10.1
-           origin_codes: i
-           status_codes: *&gt;i
-           weight: 0
-       route_distinguisher: 65000:10

</code></pre>
<h3>Step 8 — A Simple Pass/Fail Test</h3>
<p>pyATS has a built-in test framework called <code>aetest</code>. Here's the minimum useful structure: connect, check something, report pass/fail.</p>
<pre><code class="language-python">from pyats import aetest
from genie.testbed import load


class CommonSetup(aetest.CommonSetup):
    @aetest.subsection
    def connect(self, testbed):
        for device in testbed.devices.values():
            device.connect()


class CheckBGP(aetest.Testcase):
    @aetest.test
    def all_neighbors_established(self, testbed):
        router1 = testbed.devices["cisco_xrv9000"]
        bgp = router1.parse("show bgp summary")

        for instance_name, instance_data in bgp["instance"].items():
            for vrf_name, vrf_data in instance_data.get("vrf", {}).items():
                for ip, neighbor_data in vrf_data.get("neighbor", {}).items():

                    # state_pfxrcd is nested under address_family on XR
                    af_data = neighbor_data.get("address_family", {})
                    established = False

                    for af_name, af_details in af_data.items():
                        pfxrcd = af_details.get("state_pfxrcd")
                        up_down = af_details.get("up_down")

                        # A numeric state_pfxrcd means session is Established
                        if pfxrcd is not None and str(pfxrcd).isdigit():
                            established = True
                            self.passed(
                                f"[{instance_name}/{vrf_name}] {ip} ({af_name}) "
                                f"is Established — up {up_down}, pfx received: {pfxrcd}"
                            )

                    if not established:
                        self.failed(
                            f"[{instance_name}/{vrf_name}] {ip} is DOWN"
                        )


class CommonCleanup(aetest.CommonCleanup):
    @aetest.subsection
    def disconnect(self, testbed):
        for device in testbed.devices.values():
            device.disconnect()


def main():
    tb = load("testbed.yaml")
    aetest.main(testbed=tb)


main()
</code></pre>
<p>Expected Output:</p>
<pre><code class="language-python">RP/0/RP0/CPU0:cisco_xrv9000#
2026-06-22T14:10:47: %AETEST-INFO: Passed reason: [all/default] 10.10.10.1 (ipv4 unicast) is Established — up 2d22h, pfx received: 0
2026-06-22T14:10:47: %AETEST-INFO: The result of section all_neighbors_established is =&gt; PASSED
2026-06-22T14:10:47: %AETEST-INFO: The result of testcase CheckBGP is =&gt; PASSED
2026-06-22T14:10:47: %AETEST-INFO: +--------------------------+
2026-06-22T14:10:47: %AETEST-INFO: |                          Starting common cleanup            |
2026-06-22T14:10:47: %AETEST-INFO: +--------------------------+
2026-06-22T14:10:47: %AETEST-INFO: +--------------------------+
2026-06-22T14:10:47: %AETEST-INFO: |                        Starting subsection disconnect     |
2026-06-22T14:10:47: %AETEST-INFO: +--------------------------+
2026-06-22T14:10:58: %AETEST-INFO: The result of subsection disconnect is =&gt; PASSED
2026-06-22T14:10:58: %AETEST-INFO: The result of common cleanup is =&gt; PASSED
2026-06-22T14:10:58: %AETEST-INFO: +--------------------------+
2026-06-22T14:10:58: %AETEST-INFO: |                               Detailed Results                               |
2026-06-22T14:10:58: %AETEST-INFO: +--------------------------+
2026-06-22T14:10:58: %AETEST-INFO:  SECTIONS/TESTCASES                                                      RESULT
2026-06-22T14:10:58: %AETEST-INFO: ----------------------------
2026-06-22T14:10:58: %AETEST-INFO: .
2026-06-22T14:10:58: %AETEST-INFO: |--common_setup                                                        PASSED
2026-06-22T14:10:58: %AETEST-INFO: |-- connect                                                           PASSED
2026-06-22T14:10:58: %AETEST-INFO: |-- CheckBGP                                                              PASSED
2026-06-22T14:10:58: %AETEST-INFO: |-- all_neighbors_established                                       PASSED
2026-06-22T14:10:58: %AETEST-INFO: -- common_cleanup                                                        PASSED
2026-06-22T14:10:58: %AETEST-INFO -- disconnect                                                        PASSED
2026-06-22T14:10:58: %AETEST-INFO: +--------------------------+
2026-06-22T14:10:58: %AETEST-INFO: |                                   Summary                            |
2026-06-22T14:10:58: %AETEST-INFO: +--------------------------+
2026-06-22T14:10:58: %AETEST-INFO:  Number of ABORTED                                                            0
2026-06-22T14:10:58: %AETEST-INFO:  Number of BLOCKED                                                            0
2026-06-22T14:10:58: %AETEST-INFO:  Number of ERRORED                                                            0
2026-06-22T14:10:58: %AETEST-INFO:  Number of FAILED                                                             0
2026-06-22T14:10:58: %AETEST-INFO:  Number of PASSED                                                             3
2026-06-22T14:10:58: %AETEST-INFO:  Number of PASSX                                                              0
2026-06-22T14:10:58: %AETEST-INFO:  Number of SKIPPED                                                            0
2026-06-22T14:10:58: %AETEST-INFO:  Total Number                                                                 3
2026-06-22T14:10:58: %AETEST-INFO:  Success Rate                                                            100.0%
2026-06-22T14:10:58: %AETEST-INFO: ----------------------------
</code></pre>
<h1>Quick Reference Cheat Sheet</h1>
<table>
<thead>
<tr>
<th>What you want to do</th>
<th>Code</th>
</tr>
</thead>
<tbody><tr>
<td>Load your inventory</td>
<td>tb = load("testbed.yaml")</td>
</tr>
<tr>
<td>Connect to a device</td>
<td>device.connect()</td>
</tr>
<tr>
<td>Run a raw command</td>
<td>device.execute("show ...")</td>
</tr>
<tr>
<td>Get structured data</td>
<td>device.parse("show ...")</td>
</tr>
<tr>
<td>Snapshot a whole feature</td>
<td>device.learn("bgp")</td>
</tr>
<tr>
<td>Compare two snapshots</td>
<td>Diff(before, after) diff.findDiff()</td>
</tr>
<tr>
<td>Check available parsers</td>
<td>genie parsers show --os</td>
</tr>
</tbody></table>
<h1>Further Reading</h1>
<ul>
<li><p>pyATS Documentation — developer.cisco.com/docs/pyats</p>
</li>
<li><p>Genie Feature Browser — pubhub.devnetcloud.com/media/genie-feature-browser</p>
</li>
<li><p>pyATS GitHub — github.com/CiscoTestAutomation/pyats</p>
</li>
<li><p>Cisco DevNet pyATS Learning Labs — developer.cisco.com/learning</p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[NetBox Automation Cookbook ]]></title><description><![CDATA[Why Automate NetBox at all ?
If you followed my last post, you now know how to confidently navigate NetBox's GUI. Creating sites, racks, devices, prefixes, and IP addresses through the web interface. ]]></description><link>https://codednetwork.com/netbox-automation-cookbook</link><guid isPermaLink="true">https://codednetwork.com/netbox-automation-cookbook</guid><category><![CDATA[netbox]]></category><category><![CDATA[Python]]></category><category><![CDATA[GraphQL]]></category><category><![CDATA[ansible]]></category><category><![CDATA[netdevops]]></category><category><![CDATA[containerlab]]></category><dc:creator><![CDATA[Kgosi Leburu]]></dc:creator><pubDate>Wed, 10 Jun 2026 06:27:33 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/ffcec893-ac20-49db-bc86-77ac230a2cb5.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>Why Automate NetBox at all ?</h1>
<p>If you followed my last post, you now know how to confidently navigate NetBox's GUI. Creating sites, racks, devices, prefixes, and IP addresses through the web interface. For a small lab or a new deployment, that's perfectly fine.</p>
<p>But here's the reality of production network environments: you might have hundreds of devices to onboard, thousands of IP addresses to track, and a team of people who all need NetBox to reflect the truth of what's actually running in your infrastructure <em>right now</em>. Navigating a web form for each record quickly becomes impractical. Even worse, manual data entry can lead to human error such as a typo in an IP address, an incorrect VLAN assignment, or a device that was never added because someone forgot.</p>
<p>This is where automation changes everything. When NetBox is automated, it stops being a wiki that you have to remember to update and starts becoming a <strong>living</strong>, <strong>accurate source of truth</strong> that updates itself as part of your normal workflows. Devices get added when they're provisioned. IPs get allocated when servers are built. Status fields change as part of your change management process and not three days later when someone remembers.</p>
<p>In this article we look at automation approaches you can use with your running NetBox instance. Each one solves a different problem, and by the end you'll know exactly which tool to reach for in any situation.</p>
<h2>The Big Picture - What is available</h2>
<table>
<thead>
<tr>
<th>Tool</th>
<th>Category</th>
<th>Best for</th>
</tr>
</thead>
<tbody><tr>
<td>REST API</td>
<td>Direct HTTP</td>
<td>Foundation for everything; ad-hoc queries and testing</td>
</tr>
<tr>
<td>pynetbox</td>
<td>Python SDK</td>
<td>Scripts, bulk operations, scheduled reporting</td>
</tr>
<tr>
<td>Ansible</td>
<td>Config Management</td>
<td>Playbook-driven workflows, dynamic inventory</td>
</tr>
<tr>
<td>GraphQL API</td>
<td>Query Language</td>
<td>Complex nested queries with minimal round-trips</td>
</tr>
</tbody></table>
<h1>The REST API — Your Universal Entry Point</h1>
<h2>What it is</h2>
<p>Every piece of functionality in NetBox is exposed through a RESTful HTTP API. When you click 'Add Device' in the GUI, the web interface is ultimately making the same API calls that you can make directly from a terminal, a script, or any tool that can send HTTP requests. The REST API is the common denominator that everything else in this article builds on.</p>
<p>NetBox's REST API is exceptionally well-designed. Every object type such as devices, interfaces, IP addresses, prefixes, VLANs and cables has its own endpoint following a consistent <strong>URL</strong> pattern. It supports filtering, pagination, sorting, and partial updates out of the box. There's even an interactive browser at <strong>/api/</strong> where you can explore and test endpoints live in your browser.</p>
<h2>Why You'd Use It</h2>
<p>The REST API is the right choice when you need something quick and language-agnostic. If you want to verify a device exists before running a script, pull a list of IPs for a specific prefix, or test a filter's return before integrating it into your code. <strong>Curl</strong> and the <strong>REST API</strong> get you there in seconds. It's also the integration point for tools and platforms that don't have a dedicated NetBox library: monitoring systems, ticketing tools, CMDBs, and custom internal applications all speak HTTP.</p>
<div>
<div>💡</div>
<div><em>Understanding the REST API directly makes you a better user of every other tool in this list. When something isn't working in Ansible or pynetbox, knowing how to test the underlying API call directly is invaluable for debugging.</em></div>
</div>

<h3>Authentication</h3>
<p>NetBox uses token-based authentication. Generate a token in the GUI under Admin → API Tokens, then pass it in an Authorization header on every request.</p>
<p><strong>Verify your token works</strong></p>
<pre><code class="language-python">curl -s \
  -H "Authorization: Token &lt;API-token&gt;" \
  http://&lt;your-netbox&gt;:8080/api/ | python3 -m json.tool
</code></pre>
<p><strong>Expected Output</strong></p>
<pre><code class="language-python">{
    "circuits": "http://&lt;your-netbox&gt;:8080/api/circuits/",
    "core": "http://&lt;your-netbox&gt;:8080/api/core/",
    "dcim": "http://&lt;your-netbox&gt;:8080/api/dcim/",
    "extras": "http://&lt;your-netbox&gt;:8080/api/extras/",
    "ipam": "http://&lt;your-netbox&gt;:8080/api/ipam/",
    "plugins": "http://&lt;your-netbox&gt;:8080/api/plugins/",
    "status": "http://&lt;your-netbox&gt;:8080/api/status/",
    "tenancy": "http://&lt;your-netbox&gt;:8080/api/tenancy/",
    "users": "http://&lt;your-netbox&gt;:8080/api/users/",
    "virtualization": "&lt;your-netbox&gt;:8080/api/virtualization/",
    "vpn": "http://&lt;your-netbox&gt;:8080/api/vpn/",
    "wireless": "http://&lt;your-netbox&gt;:8080/api/wireless/"
}
</code></pre>
<h3>Use Case 1 — Query All Active Devices in a Site</h3>
<pre><code class="language-python">curl -s \
  -H "Authorization: Token &lt;API-token&gt;" \
  http://&lt;your-netbox&gt;:8080/api/dcim/sites/ | python3 -m json.tool
</code></pre>
<p><strong>Expected Output</strong></p>
<pre><code class="language-python">{
    "count": 3,
    "next": null,
    "previous": null,
    "results": [
        {
            "id": 1,
            "url": "http://&lt;netbox&gt;:8080/api/dcim/sites/1/",
            "display_url":"http://"&lt;netbox&gt;:8080/dcim/sites/1/",
            "display": "DC-Auckland",
            "name": "DC-Auckland",
            "slug": "dc-auckland",
            "status": {
                "value": "active",
                "label": "Active"
            },
            "region": null,
            "group": null,
            "tenant": null,
            "facility": "",
            "time_zone": "Pacific/Auckland",
            "description": "Primary data centre Auckland",
            "physical_address": "Auckland, New Zealand",
            "shipping_address": "",
            "latitude": null,
            "longitude": null,
            "owner": null,
            "comments": "",
            "asns": [
                {
                    "id": 1,
                    "url": "http://&lt;netbox&gt;:8080/api/ipam/asns/1/",
                    "display": "AS65000",
                    "asn": 65000,
                    "description": "DC-Auckland ASN"
                }
            ],
            "tags": [],
            "custom_fields": {},
            "created": "2026-05-24T23:14:48.341970Z",
            "last_updated": "2026-05-24T23:14:48.341987Z",
            "circuit_count": 0,
            "device_count": 1,
            "prefix_count": 0,
            "rack_count": 1,
            "virtualmachine_count": 0,
            "vlan_count": 3
        },
</code></pre>
<h3>Use Case 2 — Create an IP Address Record</h3>
<pre><code class="language-python">
curl -s -X POST \
  -H "Authorization: Token &lt;API-token&gt;" \
  -H "Content-Type: application/json" \
  -d '{
    "address": "192.168.10.50/24",
    "status": "active",
    "description": "Web server uplink - Rack A3"
  }' \
  http://&lt;your-netbox&gt;:8080/api/ipam/ip-addresses/
  
</code></pre>
<p><strong>Expected Output</strong></p>
<pre><code class="language-python">{"id":9,"url":"http://&lt;netbox&gt;:8080/api/ipam/ip-addresses/9/","display_url":"http://&lt;netbox&gt;                              :8080/ipam/ip-addresses/9/","display":"192.168.10.50/24","family":{"value":4,"label":"IPv4"},"                                       address":"192.168.10.50/24","vrf":null,"tenant":null,"status":{"value":"active","label":"Active"},"                                       role":null,"assigned_object_type":null,"assigned_object_id":null,"assigned_object":null,"nat_inside":null,"nat_outside":[],"dns_name":"","description":"Web server uplink - Rack A3","owner":null,"comments":"","tags":[],"custom_fields":{},"created":"2026-06-07T22:43:47.758249Z","last_updated":"2026   
</code></pre>
<p><strong>GUI Verification</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/5cc146aa-31ec-4005-bf26-c8cf37ce57d4.jpg" alt="" style="display:block;margin:0 auto" />

<h3>Use Case 3 — Partial Update with PATCH</h3>
<p>I'm using the IP address above to update its status from <strong>Active</strong> to <strong>Reserved</strong>.</p>
<pre><code class="language-python">export NB="http://&lt;your-netbox&gt;:8080"
export TOKEN="&lt;API-token&gt;"

curl -s -X PATCH \
  -H "Authorization: Token $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"status": "reserved"}' \
  $NB/api/ipam/ip-addresses/9/ | python3 -m json.tool
</code></pre>
<div>
<div>💡</div>
<div>In this example, we use variables to achieve a cleaner appearance.</div>
</div>

<p><strong>Expected Output</strong></p>
<pre><code class="language-python">{
    "id": 9,
    "url": "http://&lt;netbox&gt;:8080/api/ipam/ip-addresses/9/",
    "display_url": "http://&lt;netbox&gt;:8080/ipam/ip-addresses/9/",
    "display": "192.168.10.50/24",
    "family": {
        "value": 4,
        "label": "IPv4"
    },
    "address": "192.168.10.50/24",
    "vrf": null,
    "tenant": null,
    "status": {
        "value": "reserved",
        "label": "Reserved"
    },
    "role": null,
    "assigned_object_type": null,
    "assigned_object_id": null,
    "assigned_object": null,
    "nat_inside": null,
    "nat_outside": [],
    "dns_name": "",
    "description": "Web server uplink - Rack A3",
    "owner": null,
    "comments": "",
    "tags": [],
    "custom_fields": {},
    "created": "2026-06-07T22:43:47.758249Z",
    "last_updated": "2026-06-07T23:00:09.572431Z"
}
</code></pre>
<h3>Key Concepts</h3>
<ul>
<li><p><strong>Pagination</strong>: Results come back in pages. Use <code>?limit=100&amp;offset;=100</code> to walk through large sets. The count field tells you the total.</p>
</li>
<li><p><strong>Filtering</strong>: Append <code>?field=value</code> to almost any endpoint. Filterable fields are documented in the API browser.</p>
</li>
<li><p><strong>Depth</strong>: Add <code>?depth=1</code> to expand related objects from IDs into full nested objects.</p>
</li>
<li><p><strong>API Browser</strong>: Visit <code>https://your-netbox/api/</code> — it's fully interactive and documents every endpoint with live try-it-out functionality.</p>
</li>
</ul>
<h1>pynetbox — The Python SDK</h1>
<h2>What It Is</h2>
<p><strong>pynetbox</strong> is the official Python client library for NetBox. Rather than constructing raw HTTP requests and parsing JSON by hand, pynetbox gives you Python objects that feel natural to work with. <code>nb.dcim.devices.filter(site="sydney-dc1")</code> is far more readable than a curl command and it handles pagination, authentication headers, and error responses automatically.</p>
<h2>Why You'd Use It</h2>
<p>The true strength of pynetbox shines in bulk operations and scripting. Need to import 500 IP addresses from a spreadsheet? A 25-line Python script can manage that. Looking for a nightly inventory report? Pynetbox seamlessly integrates with csv, pandas, and other Python tools. It also effectively manages idempotency, allowing you to verify if a record exists before creating it, update records in place, and develop scripts that can be safely executed multiple times without causing duplicates. This is crucial in real-world environments where scripts are rerun and initial attempts may not always succeed.</p>
<h3>Install</h3>
<pre><code class="language-python">pip install pynetbox
</code></pre>
<h3>Connect to Netbox</h3>
<pre><code class="language-python">import pynetbox
import requests

# Setup
nb = pynetbox.api(
    "http://&lt;your-netbox&gt;:8080",
    token="&lt;API-token&gt;"
)

# Disable SSL verification (not needed for HTTP but good habit for lab)
session = requests.Session()
session.verify = False
nb.http_session = session

# Now make a call to verify the connection works
status = nb.status()
print(status)
</code></pre>
<p><strong>Expected Output</strong></p>
<pre><code class="language-python">{'django-version': '6.0.5', 'hostname': '4ba0dd1e9137', 'installed_apps': {'django_filters': '25.2', 'django_prometheus': '2.4.0', 'django_rq': '4.1.0', 'django_tables2': '2.8.0', 'drf_spectacular': '0.29.0', 'drf_spectacular_sidecar': '2026.5.1', 'mptt': '0.18.0', 'rest_framework': '3.17.1', 'social_django': '5.9.0', 'taggit': '6.1.0', 'timezone_field': '7.2.1'}, 'netbox-version': '4.6.1', 'netbox-full-version': '4.6.1-Docker-5.0.1', 'plugins': {}, 'python-version': '3.14.4', 'rq-workers-running': 1}
</code></pre>
<h2>Use Case 1 — Generate a Full Device Inventory Report</h2>
<p>One of the most common tasks in any network team: pulling a current device list into a spreadsheet for audits, compliance reports, or sharing with stakeholders who don't have NetBox access. <strong>pynetbox</strong> handles pagination automatically and simply y iterate through the data.</p>
<pre><code class="language-python">#Export all active devices to a dated CSV
import pynetbox
import csv
from datetime import datetime

nb = pynetbox.api("http://&lt;netbox&gt;:8080", token="&lt;API-token&gt;")

# Pull all active devices — pynetbox handles pagination automatically
devices = nb.dcim.devices.filter(status="active")

filename = f"netbox_inventory_{datetime.today().strftime('%Y-%m-%d')}.csv"

with open(filename, "w", newline="") as f:
    writer = csv.writer(f)
    writer.writerow(["Name", "Site", "Rack", "Role", "Model", "Platform", "Primary IP", "Status"])

    for device in devices:
        writer.writerow([
            device.name,
            str(device.site) if device.site else "",
            str(device.rack) if device.rack else "",
            str(device.device_role),
            str(device.device_type),
            str(device.platform) if device.platform else "",
            str(device.primary_ip) if device.primary_ip else "No IP",
            device.status.value,
        ])

print(f"Inventory exported to {filename}")
</code></pre>
<p><strong>Expected Output</strong></p>
<pre><code class="language-python"># The output after successful execution 
Inventory exported to netbox_inventory_2026-06-08.csv

# The information in the csv 
cat netbox_inventory_2026-06-08.csv
Name,Site,Rack,Role,Model,Platform,Primary IP,Status
core-rtr-01,DC-Auckland,AKL-RACK-01,Core Router,cisco_xrv9000,Cisco IOS-XR,10.10.10.3/24,active
core-rtr-02,DC-Sydney,SYD-RACK-01,Core Router,vMX,Juniper Junos,10.10.10.4/24,active
pe-rtr-01,DC-Queenstown,Queen-RACK-01,PE Router,7750 SR-1,Nokia SROS,10.10.10.1/24,active
pe-rtr-02,DC-Queenstown,Queen-RACK-01,PE Router,7750 SR-1,Nokia SROS,10.10.10.2/32,active
</code></pre>
<h2>Use Case 2 — Auto-Assign the Next Available IP from a Prefix</h2>
<p>This is where pynetbox really shines for IPAM automation. Instead of looking up the next free IP manually, your provisioning script calls NetBox directly and gets one allocated : <strong>no spreadsheet</strong>, <strong>no guesswork</strong>, <strong>no collisions</strong>.</p>
<pre><code class="language-python">import pynetbox

nb = pynetbox.api("http://&lt;netbox&gt;:8080", token="&lt;API-token&gt;")

def allocate_next_ip(prefix_cidr, description, dns_name=None):
    """Find and allocate the next available IP in a given prefix."""
    prefix = nb.ipam.prefixes.get(prefix=prefix_cidr)
    if not prefix:
        raise ValueError(f"Prefix {prefix_cidr} not found in NetBox")

    available = prefix.available_ips.list()
    if not available:
        raise RuntimeError(f"No available IPs remaining in {prefix_cidr}")

    new_ip = nb.ipam.ip_addresses.create(
        address=available[0]["address"],
        status="active",
        description=description,
        dns_name=dns_name or "",
    )
    print(f"[+] Allocated {new_ip.address} — {description}")
    return new_ip

# Example usage
ip = allocate_next_ip(
    prefix_cidr="10.10.10.0/24",
    description="New app server - provisioned by automation",
    dns_name="app-server-04.internal"
)
</code></pre>
<p><strong>Expected Output</strong></p>
<pre><code class="language-python">[+] Allocated 10.10.10.5/24 — New app server - provisioned by automation
</code></pre>
<div>
<div>💡</div>
<div><strong>Note</strong> : pynetbox excels when migrating from a spreadsheet-based IPAM, it saves days of manual work.</div>
</div>

<h1>Ansible — Playbook-Driven Network Automation</h1>
<h2>What It Is</h2>
<p>Ansible is the industry-standard open-source automation framework for configuration management, application deployment, and task orchestration. It uses human-readable YAML 'playbooks' to specify desired actions and communicates with devices and APIs over SSH or HTTP, eliminating the need for an agent on managed hosts. The <code>netbox.netbox</code> Ansible collection offers specialized modules for managing NetBox objects and includes a dynamic inventory plugin that converts your live NetBox data into an Ansible inventory.</p>
<h2>Why You'd Use It</h2>
<p>Ansible and NetBox complement each other by addressing different aspects of infrastructure management. NetBox maintains the intended state of your infrastructure, while Ansible ensures that the actual devices align with this state. The dynamic inventory plugin is especially effective, as it allows Ansible to query NetBox in real-time, creating a host list from live data instead of relying on a static list. This ensures your inventory is always up-to-date, automatically organized by site, device role, platform, and other attributes. When you add a device to NetBox, Ansible automatically recognizes it the next time a playbook is executed, eliminating the need for manual inventory updates.</p>
<h3>Install Ansible and the NetBox collection</h3>
<pre><code class="language-python">pip install ansible pynetbox
ansible-galaxy collection install netbox.netbox
</code></pre>
<h2>Use Case 1 — Dynamic Inventory Setup</h2>
<pre><code class="language-yaml">plugin: netbox.netbox.nb_inventory
api_endpoint: http://&lt;netbox&gt;:8080
token: nbt_vgoJvumHKFno.StatmKZYfkeHm6lQqmUWmAR88vEQMNE4urjTA7a4
validate_certs: false

# Group devices by these attributes
group_by:
  - device_roles
  - sites
  - platforms
  - tags

# Only pull active devices
query_filters:
  - status: "active"

# Map NetBox primary IP to Ansible's connection address
compose:
  ansible_host: primary_ip.address | ansible.netcommon.ipaddr('address')
</code></pre>
<p><strong>Expected Output</strong></p>
<pre><code class="language-python">ansible-inventory -i netbox_inventory.yml --graph

@all:
  |--@ungrouped:
  |--@sites_dc-auckland:
  |  |--core-rtr-01
  |--@sites_dc-queenstown:
  |  |--pe-rtr-01
  |  |--pe-rtr-02
  |--@sites_dc-sydney:
  |  |--core-rtr-02
  |--@device_roles_core-router:
  |  |--core-rtr-01
  |  |--core-rtr-02
  |--@platforms_cisco-ios-xr:
  |  |--core-rtr-01
  |--@tags_bgp-peer:
  |  |--core-rtr-01
  |  |--core-rtr-02
  |  |--pe-rtr-01
  |  |--pe-rtr-02
  |--@tags_lab:
  |  |--core-rtr-01
  |  |--core-rtr-02
  |  |--pe-rtr-01
  |  |--pe-rtr-02
  |--@tags_mpls-enabled:
  |  |--core-rtr-01
  |  |--core-rtr-02
  |  |--pe-rtr-01
  |  |--pe-rtr-02
  |--@platforms_juniper-junos:
  |  |--core-rtr-02
  |--@device_roles_pe-router:
  |  |--pe-rtr-01
  |  |--pe-rtr-02
  |--@platforms_nokia-sros:
  |  |--pe-rtr-01
  |  |--pe-rtr-02

ansible-inventory -i netbox_inventory.yml --list

{
    "_meta": {
        "hostvars": {
            "core-rtr-01": {
                "ansible_host": "10.10.10.3",
                "custom_fields": {
                    "automation_enabled": true,
                    "bgp_asn": 65000,
                    "config_backup_path": "/backups/auckland/core-rtr-01/",
                    "mgmt_vrf": "VRF-MGMT",
                    "ntp_last_deployed": null,
                    "support_contract": "PLATINUM",
                    "warranty_expiry": "2028-06-30"
                },
                "device_roles": [
                    "core-router"
                ],
                "device_types": [
                    "cisco_xrv9000"
                ],
                "is_virtual": false,
                "local_context_data": [
                    null
                ],
                "locations": [],
                "manufacturers": [
                    "cisco"
                ],
                "platforms": [
                    "cisco-ios-xr"
                ],
                "primary_ip4": "10.10.10.3",
                "racks": [
                    "AKL-RACK-01"
                ],
                "regions": [],
                "serial": "",
                "services": [],
                "site_groups": [],
                "sites": [
                    "dc-auckland"
                ],
                "status": {
                    "label": "Active",
                    "value": "active"
                },
                "tags": [
                    "bgp-peer",
                    "lab",
                    "mpls-enabled"
                ]
            },
            "core-rtr-02": {
                "ansible_host": "10.10.10.4",
                "custom_fields": {
                    "automation_enabled": true,
                    "bgp_asn": 65001,
                    "config_backup_path": "/backups/auckland/core-rtr-02/",
                    "mgmt_vrf": "VRF-MGMT",
                    "ntp_last_deployed": null,
                    "support_contract": null,
                    "warranty_expiry": null
                },
                "device_roles": [
                    "core-router"
                ],
                "device_types": [
                    "vmx"
                ],
                "is_virtual": false,
                "local_context_data": [
                    null
                ],
                "locations": [],
                "manufacturers": [
                    "juniper"
                ],
                "platforms": [
                    "juniper-junos"
                ],
                "primary_ip4": "10.10.10.4",
                "racks": [
                    "SYD-RACK-01"
                ],
                "regions": [],
                "serial": "",
                "services": [],
                "site_groups": [],
                "sites": [
                    "dc-sydney"
                ],
                "status": {
                    "label": "Active",
                    "value": "active"
                },
                "tags": [
                    "bgp-peer",
                    "lab",
                    "mpls-enabled"
                ]
            },

................... Truncated 
</code></pre>
<h1>GraphQL — Ask for Exactly What You Need</h1>
<h2>What It Is</h2>
<p>In addition to the REST API, NetBox offers a GraphQL endpoint at <strong>/graphql/</strong>. GraphQL is a query language for APIs that allows you to specify the exact data structure you need in a single request. Instead of accessing multiple REST endpoints and combining results in code, you can write a single query that navigates related objects and retrieves only the fields you require</p>
<h2>Why You'd Use It</h2>
<p>The core benefit of GraphQL over REST is efficiency in complex queries. Consider a common task: you need a list of all active devices with each device's interfaces and the IPs assigned to those interfaces.Using REST, you would need at least three separate requests: one for devices, followed by individual requests for each device's interfaces, and then additional requests for the IPs assigned to those interfaces.With GraphQL, it's a single query. This matters most when building dashboards, audit reports, or integration pipelines that need rich nested data.</p>
<p>GraphQL minimizes the need for transformation code since the response matches the requested structure. The interactive playground at <strong>/graphql/</strong> provides live schema documentation, allowing you to explore and test queries effortlessly without coding.</p>
<h2>Use Case 1 — Device with Interfaces and IPs in One Request</h2>
<p>This single query would require at least three separate REST API calls to replicate. The equivalent in REST would require:</p>
<ul>
<li><p><code>GET /api/dcim/devices/</code> — get devices</p>
</li>
<li><p><code>GET /api/dcim/interfaces/?device=x</code> — per device, get interfaces</p>
</li>
<li><p><code>GET /api/ipam/ip-addresses/?interface=x</code> — per interface, get IPs</p>
</li>
</ul>
<pre><code class="language-python">import requests
import json

NETBOX_URL = "http://&lt;netbox&gt;:8080/graphql/"
TOKEN = "nbt_vgoJvumHKFno.StatmKZYfkeHm6lQqmUWmAR88vEQMNE4urjTA7a4"

HEADERS = {
    "Authorization": f"Token {TOKEN}",
    "Content-Type": "application/json",
}

# GraphQL query to filter for dc-sydney only 

query = """
{
  device_list(filters: {site: {slug: {i_exact: "dc-sydney"}}}) {
    name
    status
    device_type {
      model
    }
    role {
      name
    }
    rack {
      name
    }
    primary_ip4 {
      address
      dns_name
    }
    interfaces {
      name
      enabled
      ip_addresses {
        address
        status
      }
    }
  }
}
"""

response = requests.post(
    NETBOX_URL,
    headers=HEADERS,
    json={"query": query},
    verify=False
)

data = response.json()

# Check for GraphQL errors
# GraphQL always returns HTTP 200 even when something goes wrong and errors come back inside the JSON body itself, not as HTTP status codes.

if "errors" in data:
    print("[!] GraphQL errors:")
    for error in data["errors"]:
        print(f"    {error['message']}")
    exit(1)

devices = data["data"]["device_list"]

if not devices:
    print("[!] No devices found")
    exit(0)

print(f"Found {len(devices)} device(s)\n")
print("=" * 65)

for device in devices:
    print(f"Device    : {device['name']}")
    print(f"Status    : {device['status']}")
    print(f"Model     : {device['device_type']['model']}")
    print(f"Role      : {device['role']['name']}")
    print(f"Rack      : {device['rack']['name'] if device['rack'] else 'N/A'}")

    if device["primary_ip4"]:
        print(f"Primary IP: {device['primary_ip4']['address']}")
        dns = device["primary_ip4"].get("dns_name", "")
        if dns:
            print(f"DNS Name  : {dns}")
    else:
        print(f"Primary IP: None")

    interfaces = device.get("interfaces", [])
    if interfaces:
        print(f"Interfaces: {len(interfaces)} found")
        for intf in interfaces:
            enabled = "up" if intf["enabled"] else "down"
            ips = intf.get("ip_addresses", [])
            if ips:
                for ip in ips:
                    print(f"  └─ {intf['name']:&lt;20} [{enabled}]  {ip['address']}                                                            ({ip['status']})")
            else:
                print(f"  └─ {intf['name']:&lt;20} [{enabled}]  no IP")
    else:
        print(f"Interfaces: None")

    print("=" * 65)
</code></pre>
<p><strong>Expected Output</strong></p>
<pre><code class="language-python">Found 1 device(s)

===============================================================
Device    : core-rtr-02
Status    : active
Model     : vMX
Role      : Core Router
Rack      : SYD-RACK-01
Primary IP: 10.10.10.4/24
Interfaces: 4 found
  └─ ge-0/0/0             [up]  no IP
  └─ ge-0/0/1             [up]  no IP
  └─ fxp0                 [up]  192.168.20.1/24  (active)
  └─ lo0                  [up]  10.10.10.4/24  (active)
===============================================================
</code></pre>
<h1>Choosing the Right Tool</h1>
<p>Here's a practical decision guide. In real environments, these tools often complement each other and the key is determining which one takes the lead for a specific task.</p>
<h2>What are you trying to do ?</h2>
<ul>
<li><p><strong>REST API (curl)</strong> - One-off query, quick test, or debugging?</p>
</li>
<li><p><strong>pynetbox</strong> - Python scripting, bulk import, or scheduled reporting?</p>
</li>
<li><p><strong>Ansible</strong> - Managing device configs alongside NetBox records?</p>
</li>
<li><p><strong>GraphQL API</strong> - Complex queries joining devices, IPs, interfaces in one call</p>
</li>
</ul>
<h1>Conclusion</h1>
<p>NetBox's value depends on the accuracy of its data. Achieving data accuracy on a large scale requires automation.</p>
<p>This article highlights an important point: NetBox is not merely a GUI with an API attached. It is an API that includes a GUI. Every click you made in my previous post has a programmatic equivalent. When your tools talk directly to NetBox , reading from it, writing to it, and reacting to changes in it. The data stays current without depending on human intervention to keep it updated.</p>
]]></content:encoded></item><item><title><![CDATA[Source of Truth with NetBox]]></title><description><![CDATA[Networks in modern enterprises and service providers are becoming more complex. Network teams handle Cisco IOS, Juniper Junos, Nokia SR OS, firewalls, wireless networks, cloud connectivity, and virtua]]></description><link>https://codednetwork.com/source-of-truth-with-netbox</link><guid isPermaLink="true">https://codednetwork.com/source-of-truth-with-netbox</guid><category><![CDATA[netbox]]></category><category><![CDATA[netboxcommunity]]></category><category><![CDATA[Docker]]></category><category><![CDATA[webhooks]]></category><category><![CDATA[Redis]]></category><category><![CDATA[postgres]]></category><category><![CDATA[Django]]></category><dc:creator><![CDATA[Kgosi Leburu]]></dc:creator><pubDate>Wed, 27 May 2026 05:50:58 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/dfd5a0f4-ff99-43a2-ac48-8ab8fe39449e.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Networks in modern enterprises and service providers are becoming more complex. Network teams handle Cisco IOS, Juniper Junos, Nokia SR OS, firewalls, wireless networks, cloud connectivity, and virtual infrastructure.</p>
<p>Traditional documentation methods, like spreadsheets and static diagrams, are no longer effective at scaling.</p>
<p>This is why modern network automation depends heavily on a <strong>Source of Truth</strong>.</p>
<h1>What is source of truth ?</h1>
<p>A Source of Truth is a centralized platform that stores authoritative infrastructure information.</p>
<p>Instead of using:</p>
<ul>
<li><p>spreadsheets</p>
</li>
<li><p>text files</p>
</li>
<li><p>Visio diagrams</p>
</li>
<li><p>disconnected databases</p>
</li>
</ul>
<p>All infrastructure data is stored centrally. This includes:</p>
<ul>
<li><p>Devices</p>
</li>
<li><p>IP addresses</p>
</li>
<li><p>VLANs</p>
</li>
<li><p>Interfaces</p>
</li>
<li><p>Sites</p>
</li>
<li><p>Racks</p>
</li>
<li><p>VRFs</p>
</li>
</ul>
<p>The <strong>Source of Truth</strong> serves as the reliable inventory for monitoring systems, automation tools, CI/CD pipelines, and validation frameworks.</p>
<h1>Why is a Source of Truth Required?</h1>
<ol>
<li><p><strong>Manual Documentation Fails</strong> - Spreadsheets and manually maintained inventories quickly become outdated.</p>
</li>
<li><p><strong>Automation Requires Reliable Data</strong> - Automation frameworks such as Ansible, Nornir , pyATS and Terraform depend on accurate infrastructure information.</p>
</li>
<li><p><strong>Multivendor Environments Increase Complexity</strong> - A Source of Truth normalizes infrastructure information across vendors.</p>
</li>
<li><p><strong>Better Operational Visibility</strong> - Network teams can quickly identify Device Locations , Interface usage , IP allocations and VLAN assignments to name a few</p>
</li>
</ol>
<h1>What is Netbox ?</h1>
<p>NetBox is a purpose-built "source of truth" (SoT) for network and infrastructure inventory, intended to be the definitive system for <strong>DCIM</strong> (data center infrastructure management) and <strong>IPAM</strong> (IP address management). It provides a structured data model, a REST + GraphQL API, change logging, webhooks, and a rich UI — all of which make it ideal as the authoritative dataset driving automation and operational workflows.</p>
<h2>What Makes NetBox Unique?</h2>
<ol>
<li><p><strong>API-First Design</strong> - NetBox was designed for automation from the beginning. Everything in NetBox can be accessed using APIs.</p>
</li>
<li><p><strong>Excellent IPAM Capabilities</strong> - NetBox provides IPv4 management , IPv6 management, VRFs , VLAN tracking and Prefix management. This reduces IP conflicts , Duplicate allocations and poor subnet tracking.</p>
</li>
<li><p><strong>Powerful Infrastructure Modeling</strong> - NetBox models real infrastructure relationships.</p>
</li>
<li><p><strong>Dynamic Inventory Generation</strong> - NetBox allows dynamic inventory generation automatically. New devices become available immediately to automation frameworks.</p>
</li>
</ol>
<h2>Why Should Network Teams Use NetBox?</h2>
<ol>
<li><p><strong>Better Documentation</strong> - NetBox replaces spreadsheets, disconnected inventories and static diagrams with centralized live documentation.</p>
</li>
<li><p><strong>Improved Automation</strong> - NetBox enables Dynamic inventories , Configuration generation , Compliance validation and Automated provisioning</p>
</li>
<li><p><strong>Faster Troubleshooting</strong> - Engineers can quickly identify Device ownership , Interface mappings , VLAN relationships and Rack locations</p>
</li>
<li><p><strong>Scalable Operations</strong> - As networks grow, manual tracking becomes impossible. NetBox enables scalable operational workflows.</p>
</li>
</ol>
<h2>Populating Data in NetBox</h2>
<table>
<thead>
<tr>
<th>Method</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Manual Object Creation</strong></td>
<td>The easiest and most straightforward method to populate data in NetBox is by using the object creation forms available in the user interface (UI).</td>
</tr>
<tr>
<td><strong>Bulk Import (CSV/YAML)</strong></td>
<td>NetBox supports the bulk import and updating of objects using CSV-formatted data. This method is ideal for importing spreadsheet data, which can be easily converted to CSV. You can import CSV data either as raw text in the form field or by uploading a correctly formatted CSV file.</td>
</tr>
<tr>
<td><strong>Scripting</strong></td>
<td>You will find that data you need to populate in NetBox can be easily reduced to a pattern. A simple custom script to automatically populate this information can be created</td>
</tr>
<tr>
<td><strong>REST API</strong></td>
<td>The REST API can also be used to populate data in NetBox, providing full programmatic control over object creation while adhering to the same validation rules as the UI forms. Furthermore, it supports the bulk creation of multiple objects with a single request.</td>
</tr>
</tbody></table>
<p>Rather than jumping straight into code, we start where every network team should — with the <strong>NetBox GUI</strong>. Getting your data model right in the UI first means your automation scripts will query clean, consistent, well-structured data.</p>
<blockquote>
<p>The principle of "garbage in, garbage out" is especially relevant to network automation.</p>
</blockquote>
<h1>Lab Topology</h1>
<table>
<thead>
<tr>
<th>Device</th>
<th>Vendor</th>
<th>Os</th>
<th>Site</th>
<th>Mgmt IP</th>
<th>Loopback</th>
</tr>
</thead>
<tbody><tr>
<td>core-rtr-01</td>
<td>Cisco</td>
<td>IOS-XR</td>
<td>DC-Auckland</td>
<td>192.168.10.1</td>
<td>10.10.10.3/32</td>
</tr>
<tr>
<td>core-rtr-02</td>
<td>Juniper</td>
<td>Junos</td>
<td>DC-Sydney</td>
<td>192.168.20.1</td>
<td>10.10.10.4/32</td>
</tr>
<tr>
<td>pe-rtr-01</td>
<td>Nokia</td>
<td>SR OS</td>
<td>DC-Queenstown</td>
<td>192.168.30.1</td>
<td>10.10.10.2/32</td>
</tr>
<tr>
<td>pe-rtr-02</td>
<td>Nokia</td>
<td>SR OS</td>
<td>DC-Queenstown</td>
<td>192.168.30.2</td>
<td>10.10.10.1/32</td>
</tr>
</tbody></table>
<h1>Install NetBox and Access the GUI</h1>
<p>NetBox operates as a web application supported by PostgreSQL and Redis. The quickest method to obtain a fully functional instance is by using <strong>netbox-docker</strong>, an officially maintained Docker Compose stack that automatically manages all dependencies.</p>
<h2>Installation</h2>
<p>Clone netbox-docker and Configure Environment</p>
<pre><code class="language-python"># Clone the official netbox-docker repo — always use the release branch
git clone -b release https://github.com/netbox-community/netbox-docker.git
cd netbox-docker
</code></pre>
<p>Create <code>docker-compose.override.yml</code> to set port and credentials:</p>
<pre><code class="language-python">cat &gt; docker-compose.override.yml &lt;&lt; 'EOF'
services:
  netbox:
    ports:
      - "8080:8080"
    environment:
      - SUPERUSER_NAME=admin
      - SUPERUSER_EMAIL=devplayground.gmail.com
      - SUPERUSER_PASSWORD=CodedNetbox2026!
      - ALLOWED_HOSTS=*
EOF
</code></pre>
<p>Pull Images and Start NetBox</p>
<pre><code class="language-python"># Pull all required images (netbox, postgres, redis, nginx)
docker compose pull

# Start all services in detached mode
docker compose up -d

# Watch startup logs — wait for 'Listening at: http://0.0.0.0:8080'
docker compose logs -f netbox

# Check all containers are healthy
docker compose ps
</code></pre>
<p>Expected output of <code>docker compose ps</code> :</p>
<pre><code class="language-python">NAME                          STATUS          PORTS
netbox-docker-netbox-1        Up (healthy)    0.0.0.0:8080-&gt;8080/tcp
netbox-docker-postgres-1      Up (healthy)    5432/tcp
netbox-docker-redis-1         Up (healthy)    6379/tcp
netbox-docker-worker-1        Up (healthy)
netbox-docker-housekeeping-1  Up (healthy)
</code></pre>
<div>
<div>💡</div>
<div>The initial startup takes 3-5 minutes as Docker pulls images and NetBox performs database migrations. Later startups take less than 30 seconds. If 'netbox' remains in 'starting' status for more than 5 minutes, execute 'docker compose logs netbox' to identify any errors.</div>
</div>

<h2>Access the NetBox GUI</h2>
<p>Open your browser and navigate to:</p>
<pre><code class="language-python">http://localhost:8080

# If running on a remote server, replace localhost with the server IP:
http://&lt;host-ip&gt;:8080
</code></pre>
<div>
<div>💡</div>
<div><strong>Note</strong> : port 8080 must be opened on the host machine</div>
</div>

<h3>You will see the NetBox login page</h3>
<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/8ef8a373-f77c-4233-bcd5-586e27936b53.jpg" alt="" style="display:block;margin:0 auto" />

<h3>What You See After Logging In</h3>
<p>The NetBox dashboard displays a summary of all object counts, including devices, IPs, prefixes, and VLANs. Upon first login, all values will be zero, which is normal. The top navigation bar features six main menus:</p>
<table>
<thead>
<tr>
<th>Menu</th>
<th>What Lives Here</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Organization</strong></td>
<td>Sites, racks, tenants, contacts, locations</td>
</tr>
<tr>
<td><strong>Devices</strong></td>
<td>Devices, device types, manufacturers, roles, platforms, cables</td>
</tr>
<tr>
<td><strong>IPAM</strong></td>
<td>IP addresses, prefixes, VLANs, RIRs, aggregates, services</td>
</tr>
<tr>
<td><strong>Circuits</strong></td>
<td>Providers, circuits, circuit terminations</td>
</tr>
<tr>
<td><strong>Virtualization</strong></td>
<td>Clusters, virtual machines, VM interfaces</td>
</tr>
<tr>
<td><strong>Operations</strong></td>
<td>Webhooks, event rules, scripts, reports, changelog</td>
</tr>
<tr>
<td><strong>Customization</strong></td>
<td>Custom fields, custom links, export templates, tags</td>
</tr>
<tr>
<td><strong>Admin</strong></td>
<td>Users, groups, API tokens, object permissions</td>
</tr>
</tbody></table>
<h2>Generate an API Token (Required for Later Automation)</h2>
<p><strong>GUI</strong> : Admin → API Tokens → + Add</p>
<p>Although this post emphasizes the GUI, creating a token now will set you up for future automation</p>
<table>
<thead>
<tr>
<th>Field</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td><strong>User</strong></td>
<td>admin (your superuser)</td>
</tr>
<tr>
<td><strong>Key</strong></td>
<td>(leave blank — auto-generated)</td>
</tr>
<tr>
<td><strong>Write Enabled</strong></td>
<td>✓ Checked</td>
</tr>
<tr>
<td><strong>Expires</strong></td>
<td>(leave blank for lab, set expiry for production)</td>
</tr>
<tr>
<td><strong>Description</strong></td>
<td>Lab automation token</td>
</tr>
</tbody></table>
<p>Click <strong>Save</strong>. The token will be displayed only once, so make sure to copy and store it securely.</p>
<pre><code class="language-python"># Quick API test

curl -X GET \
-H "Authorization: Bearer &lt;token&gt;" \
-H "Content-Type: application/json" \
-H "Accept: application/json; indent=4" \
http://&lt;local-host&gt;:8080/api/status/

# Successful response 

{
    "django-version": "6.0.5",
    "hostname": "4ba0dd1e9137",
    "installed_apps": {
        "django_filters": "25.2",
        "django_prometheus": "2.4.0",
        "django_rq": "4.1.0",
        "django_tables2": "2.8.0",
        "drf_spectacular": "0.29.0",
        "drf_spectacular_sidecar": "2026.5.1",
        "mptt": "0.18.0",
        "rest_framework": "3.17.1",
        "social_django": "5.9.0",
        "taggit": "6.1.0",
        "timezone_field": "7.2.1"
    },
    "netbox-version": "4.6.1",
    "netbox-full-version": "4.6.1-Docker-5.0.1",
    "plugins": {},
    "python-version": "3.14.4",
    "rq-workers-running": 1
</code></pre>
<h2>Useful Docker Management Commands</h2>
<table>
<thead>
<tr>
<th>Task</th>
<th>Command</th>
</tr>
</thead>
<tbody><tr>
<td>Stop NetBox</td>
<td>docker compose down</td>
</tr>
<tr>
<td>Start NetBox</td>
<td>docker compose up -d</td>
</tr>
<tr>
<td>View live logs</td>
<td>docker compose logs -f netbox</td>
</tr>
<tr>
<td>Check container health</td>
<td>docker compose ps</td>
</tr>
<tr>
<td>Restart a single service</td>
<td>docker compose restart netbox</td>
</tr>
<tr>
<td>Access the NetBox shell</td>
<td>docker compose exec netbox /bin/bash</td>
</tr>
<tr>
<td>Backup the database</td>
<td>docker compose exec postgres pg_dump -U netbox netbox &gt; netbox_backup.sql</td>
</tr>
</tbody></table>
<h1>Organization - Sites , Racks and Location</h1>
<h2>Create Sites</h2>
<p><strong>GUI</strong> : Organization → Sites → + Add</p>
<p>Fill in the form for DC-Auckland :</p>
<table>
<thead>
<tr>
<th>Field</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Name</strong></td>
<td>DC-Auckland</td>
</tr>
<tr>
<td><strong>Slug</strong></td>
<td>dc-auckland (auto-fills)</td>
</tr>
<tr>
<td><strong>Status</strong></td>
<td>Active</td>
</tr>
<tr>
<td><strong>ASN</strong></td>
<td>65000</td>
</tr>
<tr>
<td><strong>Time Zone</strong></td>
<td>Pacific/Auckland</td>
</tr>
<tr>
<td><strong>Description</strong></td>
<td>Primary data centre — Auckland</td>
</tr>
<tr>
<td><strong>Physical Address</strong></td>
<td>Auckland, New Zealand</td>
</tr>
</tbody></table>
<p>Click <strong>Save</strong> . Repeat the same process for other sites</p>
<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/b530fe64-30ca-41b7-b6a6-f44171241682.jpg" alt="" style="display:block;margin:0 auto" />

<div>
<div>💡</div>
<div><strong>Tip</strong> : The ASN field on the site is useful for BGP automation. Your scripts can retrieve the correct local-AS directly from NetBox, eliminating the need to hard-code it for each device or playbook.</div>
</div>

<h2>Create Rack Groups and Racks</h2>
<p><strong>GUI</strong> : Organization → Racks → Rack Groups → + Add</p>
<table>
<thead>
<tr>
<th>Field</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Name</strong></td>
<td>AKL-ROW-A</td>
</tr>
<tr>
<td><strong>Slug</strong></td>
<td>akl-row-a</td>
</tr>
<tr>
<td><strong>Site</strong></td>
<td>DC-Auckland</td>
</tr>
</tbody></table>
<p><strong>GUI</strong> : Organization → Racks → + Add</p>
<table>
<thead>
<tr>
<th>Field</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Name</strong></td>
<td>AKL-RACK-01</td>
</tr>
<tr>
<td><strong>Site</strong></td>
<td>DC-Auckland</td>
</tr>
<tr>
<td><strong>Rack Group</strong></td>
<td>AKL-ROW-A</td>
</tr>
<tr>
<td><strong>Status</strong></td>
<td>Active</td>
</tr>
<tr>
<td><strong>Type</strong></td>
<td>4-post enclosed</td>
</tr>
<tr>
<td><strong>Height (U)</strong></td>
<td>42U</td>
</tr>
</tbody></table>
<div>
<div>💡</div>
<div><strong>Note</strong>: When the rack is populated, space utilization is highlighted.</div>
</div>

<h1>Device Types &amp; Manufacturers - Hardware Models, Roles, and Platforms</h1>
<h2>Add Manufacturers</h2>
<p><strong>GUI</strong> : Device → Device Types → Manufacturers → + Add</p>
<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/7791d24b-dd8c-44f5-9f04-c5b780d3aa85.jpg" alt="" style="display:block;margin:0 auto" />

<h2>Add Device Types — Cisco xrv9k (IOS-XR)</h2>
<p><strong>GUI</strong> : Device → Device Types → + Add</p>
<table>
<thead>
<tr>
<th>Field</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Manufacturer</strong></td>
<td>Cisco</td>
</tr>
<tr>
<td><strong>Model</strong></td>
<td>cisco_xrv9000</td>
</tr>
<tr>
<td><strong>Slug</strong></td>
<td>cisco_xrv9000</td>
</tr>
<tr>
<td><strong>Part Number</strong></td>
<td>8HE03685AARA01</td>
</tr>
<tr>
<td><strong>Height (U)</strong></td>
<td>1</td>
</tr>
<tr>
<td><strong>Full Depth</strong></td>
<td>✓ Checked</td>
</tr>
<tr>
<td><strong>Comments</strong></td>
<td>Virtual IOS-XR router</td>
</tr>
</tbody></table>
<p>After saving, click the <strong>Interfaces</strong> tab → <strong>Add Interface Template</strong>:</p>
<table>
<thead>
<tr>
<th>Interface Name</th>
<th>Type</th>
<th>Management Only</th>
</tr>
</thead>
<tbody><tr>
<td>GigabitEthernet1</td>
<td>1000BASE-T (1GE)</td>
<td>✓ Yes</td>
</tr>
<tr>
<td>GigabitEthernet2</td>
<td>1000BASE-T (1GE)</td>
<td>No</td>
</tr>
<tr>
<td>GigabitEthernet3</td>
<td>1000BASE-T (1GE)</td>
<td>No</td>
</tr>
<tr>
<td>Loopback0</td>
<td>Virtual</td>
<td>No</td>
</tr>
</tbody></table>
<div>
<div>💡</div>
<div><strong>Tip</strong> : Interface templates automatically populate each device created from this type. Your automation scripts can reliably use <code>device.interfaces.all()</code> to find consistent, vendor-accurate interface names, eliminating the need for guessing or hard-coding.</div>
</div>

<h2>Add Device Roles</h2>
<p><strong>GUI</strong> : Device → Device Roles → + Add</p>
<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/7ab1c4a3-3db0-4014-b357-baa744128cd3.jpg" alt="" style="display:block;margin:0 auto" />

<h2>Add Platforms</h2>
<p><strong>GUI</strong> : Device → Platforms → + Add</p>
<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/98f1c871-a7e8-44eb-97cf-03ff3825e04b.jpg" alt="" style="display:block;margin:0 auto" />

<div>
<div>💡</div>
<div><strong>Note </strong>: The platform slug is referenced in your Python PLATFORM_MAP as follows: {'ios-xr': 'cisco_xr, 'junos': 'juniper_junos', 'sros': 'nokia_sros'}. Ensure slugs are lowercase and hyphenated, as they are crucial for automation logic.</div>
</div>

<h1>Devices</h1>
<h2>Add core-rtr-01 — Cisco IOS-XR (DC-Auckland)</h2>
<p><strong>GUI</strong> : Device → Devices → + Add</p>
<table>
<thead>
<tr>
<th>Field</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Name</strong></td>
<td>core-rtr-01</td>
</tr>
<tr>
<td><strong>Device Role</strong></td>
<td>Core Router</td>
</tr>
<tr>
<td><strong>Device Type</strong></td>
<td>cisco_xrv9000</td>
</tr>
<tr>
<td><strong>Platform</strong></td>
<td>Cisco IOS-XR</td>
</tr>
<tr>
<td><strong>Site</strong></td>
<td>DC-Auckland</td>
</tr>
<tr>
<td><strong>Status</strong></td>
<td>Active</td>
</tr>
<tr>
<td><strong>Rack</strong></td>
<td>AKL-RACK-01</td>
</tr>
</tbody></table>
<p>Click <strong>Save</strong> to navigate to the device detail page. Leave the Primary IP blank for now; it will be assigned after creating the IP address record.</p>
<div>
<div>💡</div>
<div>Verify: Devices → Devices should now list all four routers.</div>
</div>

<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/ddd0063d-5d6a-4c46-92af-b0b60413d6c9.jpg" alt="" style="display:block;margin:0 auto" />

<h1>IPAM - RIRs, Prefixes, IP Addresses, and Primary IPs</h1>
<h2>Create RIRs</h2>
<p><strong>GUI</strong> : IPAM → Aggregates → RIR → + Add</p>
<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/2cc4174e-6603-4459-b879-c3e646030d2d.jpg" alt="" style="display:block;margin:0 auto" />

<div>
<div>💡</div>
<div><strong>RFC6996</strong> is the correct RIR name for private AS numbers (64512–65534)</div>
</div>

<h2>Create Prefix Roles and Prefixes</h2>
<p><strong>GUI</strong> : IPAM → Prefixes → Prefix &amp; VLAN Roles → + Add</p>
<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/a8ae01dd-3210-4fa8-839d-de76f8dede5c.jpg" alt="" style="display:block;margin:0 auto" />

<p><strong>GUI</strong> : IPAM → Prefixes → + Add</p>
<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/e085e3a8-767b-452b-b8b7-5e279d0ef1c0.jpg" alt="" style="display:block;margin:0 auto" />

<h2>Add IP Addresses and Assign to Interfaces</h2>
<p><strong>GUI</strong> : IPAM → IP Addresses → + Add</p>
<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/426ec39c-95bb-405d-a0e4-46cb190cf0ed.jpg" alt="" style="display:block;margin:0 auto" />

<h2>Set Primary IP on Each Device</h2>
<p><strong>GUI</strong> : Devices → Devices → Edit</p>
<table>
<thead>
<tr>
<th>Field</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Primary IPv4</strong></td>
<td>Select the management IP from the dropdown</td>
</tr>
</tbody></table>
<div>
<div>💡</div>
<div><strong>Tip </strong>: The Primary IPv4 field is crucial for automation. The Ansible NetBox inventory plugin relies on primary_ip4 as ansible_host. Without a primary IP, the device is excluded from the inventory and skipped by all playbooks.</div>
</div>

<h1>Custom Fields - Extending Netbox for Automation</h1>
<h2>Create Custom Fields</h2>
<p><strong>GUI</strong> : Customization → Custom Fields → + Add</p>
<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/a5908b61-1dfd-4a93-bdff-4bbd894dd8a7.jpg" alt="" style="display:block;margin:0 auto" />

<h2>Populate Custom Fields on Each Device</h2>
<p><strong>GUI</strong> : Devices → Devices → Edit → (scroll to Custom Fields)</p>
<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/a2524508-7afb-4bc4-b8bb-38f556ab65fb.jpg" alt="" style="display:block;margin:0 auto" />

<h1>Tags - Flexible Multi-Dimensional Grouping</h1>
<h2>Create Tags</h2>
<p><strong>GUI</strong> : Customization → Tags → + Add</p>
<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/ce068a71-3a17-48ab-9abe-04cfbe8886fa.jpg" alt="" style="display:block;margin:0 auto" />

<div>
<div>💡</div>
<div>Tags used in ansible playbooks <code>nb.dcim.devices.filter(tag="bgp-peer")</code></div>
</div>

<h2>Apply Tags to Devices</h2>
<p><strong>GUI</strong> : Devices → Devices → Edit → Tags Field</p>
<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/6134cd22-d8fc-47f6-9ad7-fe09a328bd25.jpg" alt="" style="display:block;margin:0 auto" />

<div>
<div>💡</div>
<div><strong>Tip</strong> : Tags can be queried through the API: <code>nb.dcim.devices.filter(tag='bgp-peer') </code>retrieves only BGP-speaking routers. This allows you to write targeted playbooks, such as 'push new BGP password to all devices tagged as bgp-peer,' without needing separate manual lists.</div>
</div>

<h1>Cables - Documenting Physical Connectivity</h1>
<h2>Add a Cable Between core-rtr-01 and pe-rtr-01</h2>
<p><strong>GUI</strong> : Devices → Devices (choose site) → Interfaces Tab → Connect (cable-icon)</p>
<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/8c6be79c-8729-46f5-a16e-9aaba45009dd.jpg" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/b7eacf83-7cbe-4c33-bc7b-30bbc5d6262c.jpg" alt="" style="display:block;margin:0 auto" />

<h1>Webhooks &amp; Event Rules - GUI Configured Event-Driven Automation</h1>
<h2>Create a Webhook</h2>
<p><strong>GUI</strong> : Integrations → Webhook → + Add</p>
<table>
<thead>
<tr>
<th>Field</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Name</strong></td>
<td>device-change-to-automation</td>
</tr>
<tr>
<td><strong>URL</strong></td>
<td><a href="http://your-automation-host:9000/webhook/netbox">http://your-automation-host:9000/webhook/netbox</a></td>
</tr>
<tr>
<td><strong>HTTP Method</strong></td>
<td>POST</td>
</tr>
<tr>
<td><strong>HTTP Content Type</strong></td>
<td>application/json</td>
</tr>
<tr>
<td><strong>Secret</strong></td>
<td></td>
</tr>
<tr>
<td><strong>SSL Verification</strong></td>
<td>Unchecked for lab ✓ Checked for production</td>
</tr>
</tbody></table>
<h2>Create Event Rules</h2>
<p><strong>GUI</strong> : Integrations → Event Rules → + Add</p>
<h3>Rule 2 — Device Status Changed to Active (with Condition):</h3>
<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/64244322-6177-48da-941c-199af6f41b9b.jpg" alt="" style="display:block;margin:0 auto" />

<p>In the <strong>Conditions</strong> field, the below JSON is used to filter on status change:</p>
<pre><code class="language-json">{
  "and": [
    {
      "attr": "status.value",
      "value": "active"
    }
  ]
}
</code></pre>
<div>
<div>💡</div>
<div><strong>Tip</strong> : The Conditions filter helps prevent webhook spam with every edit. Without it, any change to a device, even minor ones like adding a comment, would trigger the webhook. Use conditions to focus on the specific state transitions that are relevant to your automation.</div>
</div>

<h2>Test the Webhook with a Quick Listener</h2>
<p>In a terminal, start a one-liner HTTP listener:</p>
<pre><code class="language-python">python3 -c "
from http.server import HTTPServer, BaseHTTPRequestHandler
import json

class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        length = int(self.headers.get('Content-Length', 0))
        raw = self.rfile.read(length)
        data = json.loads(raw)
        print(json.dumps(data, indent=2))
        self.send_response(200)
        self.end_headers()
    def log_message(self, *args):
        pass

print('Listening on port 9000...')
HTTPServer(('', 9000), Handler).serve_forever()
"
</code></pre>
<p>In the NetBox GUI, edit <strong>pe-rtr-02</strong>, change Status from Active → Planned → Active. Expected output:</p>
<pre><code class="language-python">Listening on port 9000...
{
  "event": "updated",
  "timestamp": "2026-05-27T01:53:21.485685+00:00",
  "object_type": "dcim.device",
  "username": "admin",
  "request_id": "21724c08-cb44-4961-bde1-f76fd4d9fd89",
  "data": {
    "id": 4,
    "url": "/api/dcim/devices/4/",
    "display_url": "/dcim/devices/4/",
    "display": "pe-rtr-02",
    "name": "pe-rtr-02",
    "device_type": {
      "id": 3,
      "url": "/api/dcim/device-types/3/",
      "display": "7750 SR-1",
      "manufacturer": {
        "id": 2,
        "url": "/api/dcim/manufacturers/2/",
        "display": "nokia",
        "name": "nokia",
        "slug": "nokia",
        "description": ""
      },
      "model": "7750 SR-1",
      "slug": "7750-sr-1",
      "description": "",
      "device_count": 2
    },
</code></pre>
<h1>My personal opinion about NetBox</h1>
<p>For a long time, I assumed NetBox was exclusively for production use, primarily employed by enterprise teams to document campus networks and validate their IPAM budgets. I considered it unnecessary for my lab, thinking it was "just a lab."</p>
<p>Then I began using containerlab in a more serious manner.</p>
<h2>The Problem Nobody Talks About</h2>
<p>Containerlab is truly impressive. You create a YAML topology file, execute containerlab deploy, and in less than a minute, you have a fully connected network of Cisco XRv9000s, Juniper vMX routers, and Nokia SR OS nodes communicating seamlessly. The first experience feels magical.</p>
<p>But here's what happens after the first time.</p>
<p>You create a topology for BGP testing, then another for MPLS, followed by one for SR-MPLS, and a data center fabric. When someone requests you to replicate a production issue, you quickly set up a topology that somewhat resembles it. Soon, you find yourself with six topology files in different stages of completion, a folder full of configurations with names like <code>final_v2_actually_final.cfg</code>, and no clear idea of which IP addresses were used where.</p>
<p>You SSH into something and get a different device than you expected. You run an Ansible playbook and it hits the wrong node. You try to reproduce a test from two weeks ago and spend an hour figuring out what the management IPs were.</p>
<p>This is the <strong>source-of-truth</strong> problem. And it doesn't only affect production networks.</p>
<h2>Why Netbox matters ?</h2>
<p>A Source of Truth is now essential in modern networking. NetBox offers the centralized, automation-ready infrastructure platform needed for multi-vendor environments in my case. Modern automation starts with reliable infrastructure data, and NetBox provides that foundation.</p>
]]></content:encoded></item><item><title><![CDATA[ Event-Driven Network Automation with Nokia SR OS and Ansible EDA]]></title><description><![CDATA[The problem with Polling
Every network operations team is familiar with the scenario: a ticket arrives at 3 a.m. indicating a BGP session has been down for 45 minutes. The monitoring system, checking ]]></description><link>https://codednetwork.com/event-driven-network-automation-with-nokia-sr-os-and-ansible-eda</link><guid isPermaLink="true">https://codednetwork.com/event-driven-network-automation-with-nokia-sr-os-and-ansible-eda</guid><category><![CDATA[YAML]]></category><category><![CDATA[NetworkAutomation]]></category><category><![CDATA[event-driven-architecture]]></category><category><![CDATA[Devops]]></category><category><![CDATA[ansible]]></category><dc:creator><![CDATA[Kgosi Leburu]]></dc:creator><pubDate>Wed, 13 May 2026 07:34:36 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/8b075bed-a8ca-4fba-9fb7-0eed4a294b0a.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>The problem with Polling</h1>
<p>Every network operations team is familiar with the scenario: a ticket arrives at 3 a.m. indicating a BGP session has been down for 45 minutes. The monitoring system, checking every five minutes, detected the issue on the third attempt and took an additional 10 minutes to alert someone. By the time it was addressed, the network had been compromised for nearly an hour.</p>
<p>This is the core issue with scheduled, polling-based automation. Problems are checked at fixed intervals, so if something fails between checks, the network remains compromised until the next cycle detects it.</p>
<p>Event-driven automation completely transforms this approach. Rather than periodically asking the infrastructure "are you OK?", the infrastructure immediately notifies you of any changes, triggering an instant response.</p>
<blockquote>
<p>"Instead of asking the network if it's OK every five minutes, the network tells you the moment something changes — and a response fires in under a second."</p>
</blockquote>
<p>Ansible Event-Driven Automation (EDA) is Red Hat's answer to this challenge built directly on top of the Ansible ecosystem most network teams already know. But it is not the first tool in this space. StackStorm has been doing event-driven automation since 2014.</p>
<h1>Ansible EDA Explained</h1>
<p>Ansible EDA was launched in 2022 and became generally available with Ansible Automation Platform 2.4 in 2023. It introduces a dedicated event-processing layer to the existing Ansible ecosystem, capable of simultaneously listening to numerous event sources and responding to them in real time.</p>
<p>The three core concepts are simple:</p>
<p>• <strong>Sources</strong> — where events come from: webhooks, Kafka topics, syslog streams, cloud alerts, Git commits, SNMP traps, monitoring system callbacks.</p>
<p>• <strong>Rules</strong> — conditions you define in YAML. If the event payload matches, an action fires. Rules can include throttling, grouping, and logical operators.</p>
<p>• <strong>Actions</strong> — what happens: run a playbook, post to Slack, open a ticket, set a variable, or simply log the event.</p>
<p>These are tied together in a Rulebook — a YAML file that looks like this:</p>
<pre><code class="language-yaml">---
- name: React to BGP session down
  hosts: network_devices
  sources:
    - ansible.eda.syslog:
        host: 0.0.0.0
        port: 5514

  rules:
    - name: BGP neighbor down
      condition: &gt;
        event.message is search("bgpSessionDown", ignorecase=true)
      throttle:
        once_within: 60 seconds
        group_by_attributes:
          - event.host
      actions:
        - run_playbook:
            name: playbooks/bgp_remediate.yml
            extra_vars:
              source_host: "{{ event.host }}"
</code></pre>
<p>This forms the complete loop: <code>source — condition — action</code>. If you're familiar with Ansible, you already grasp 80% of EDA. The rulebook is the only new concept, and it reads like plain English.</p>
<h2>What EDA is not</h2>
<p>EDA is not a monitoring system; it doesn't generate events but responds to them. You still require a source, such as a syslog stream, a gNMI telemetry collector feeding Kafka, a Prometheus Alertmanager webhook, or a custom script. EDA serves as the reaction layer, not the observability layer. It is also not (yet) a fully stateful workflow engine. For long-running, multi-step orchestration with complex branching logic, Ansible Automation Platform's Workflows feature or a dedicated orchestrator is more suitable. EDA is optimized for quick, reactive, event-triggered automation.</p>
<h1>Why EDA matters now ?</h1>
<ol>
<li><p><strong>Networks Are Too Dynamic for Polling</strong> - Modern network environments—such as SD-WAN fabrics, containerized services, and cloud-native workloads—change states much faster than traditional infrastructure. A BGP session can fluctuate and recover in less than 30 seconds. An interface might experience a temporary optical issue and resolve itself. Polling-based tools capture the aftermath, not the event itself. EDA captures both.</p>
</li>
<li><p><strong>Operator time is the bottleneck</strong> - The network operations talent gap is real and growing. Every alert that requires a human to log in, assess, and manually run a CLI command is wasted capacity. Closed-loop automation — where EDA detects an event, fires a playbook, and resolves the issue without paging anyone — frees operators to work on architecture, capacity, and projects instead of reactive fire-fighting</p>
</li>
<li><p><strong>The Ansible Ecosystem Is Already There</strong> - This is EDA's single biggest advantage over purpose-built event-driven tools. If your team already has Ansible playbooks for network configuration, NETCONF-based remediation, or cloud provisioning, those playbooks work unchanged in EDA. There is no rewrite, no new DSL to learn, no separate execution engine to operate. The activation cost is dramatically lower than adopting an entirely new platform.</p>
</li>
<li><p><strong>Kafka and gNMI Are Now Mainstream</strong> - Event-driven automation requires event streams. Coupler years ago, getting gNMI telemetry out of a network device and into an automation system was a niche skill. Today, gNMIc, Telegraf, and vendor-native streaming telemetry are standard practice. Kafka is ubiquitous. The infrastructure for event-driven automation is mature — EDA's role is to act on those streams, and it does so natively.</p>
</li>
<li><p><strong>The Throttle and Group Model Solves the Alert Storm Problem</strong> - Anyone who has tried to build event-driven network automation before has hit the alert storm problem: one event generates hundreds of syslog messages, triggers hundreds of playbook runs simultaneously, and overwhelms both the automation system and the network device it is trying to fix. EDA's built-in throttle block — <code>once_within: 60 seconds</code>, <code>group_by_attributes: [event.host]</code> — handles this correctly at the rulebook level, without requiring custom deduplication code.</p>
</li>
</ol>
<h1>EDA vs Alternatives</h1>
<p>Ansible EDA is not the only event-driven automation tool available. StackStorm, Rundeck (now Process Automation) have all been in production environments for years. Each solves a slightly different problem. Here is an honest look at where each one excels and where it falls short.</p>
<h2>Head-to-Head Comparison</h2>
<table>
<thead>
<tr>
<th>Feature</th>
<th>Ansible EDA</th>
<th>StackStorm</th>
<th>Rundeck</th>
</tr>
</thead>
<tbody><tr>
<td>YAML-native config</td>
<td>Strong</td>
<td>partial</td>
<td>partial</td>
</tr>
<tr>
<td>Reuse existing automation</td>
<td>Strong</td>
<td>absent</td>
<td>partial</td>
</tr>
<tr>
<td>Kafka / streaming source</td>
<td>Strong</td>
<td>Strong</td>
<td>absent</td>
</tr>
<tr>
<td>syslog source built-in</td>
<td>Strong</td>
<td>partial</td>
<td>absent</td>
</tr>
<tr>
<td>gNMI / telemetry native</td>
<td>partial</td>
<td>partial</td>
<td>absent</td>
</tr>
<tr>
<td>NETCONF / nokia.sros support</td>
<td>Strong</td>
<td>partial</td>
<td>absent</td>
</tr>
<tr>
<td>Low learning curve</td>
<td>Strong</td>
<td>partial</td>
<td>partial</td>
</tr>
<tr>
<td>Production maturity</td>
<td>partial</td>
<td>Strong</td>
<td>Strong</td>
</tr>
</tbody></table>
<h2>Choose Ansible EDA</h2>
<p>• If your team already uses Ansible playbooks for any automation tasks</p>
<p>• If you need event-driven reactions to network syslog, gNMI, or webhooks</p>
<p>• Time-to-value matters — you want something running in hours, not weeks</p>
<p>• If your events come from Kafka, webhooks, or syslog streams</p>
<p>• If you are working with Nokia SR OS, Cisco, Arista, or Juniper via NETCONF</p>
<h1>Ansible EDA meets Nokia SR OS</h1>
<h2>Overview</h2>
<p>This guide provides a step-by-step guide for setting up Ansible Event-Driven Automation (EDA) with Nokia SR OS nodes running in <a href="https://containerlab.dev/">Containerlab</a> . By the end, you will have:</p>
<ul>
<li><p>A Python virtual environment with Ansible EDA and the <strong>nokia.sros</strong> collection</p>
</li>
<li><p>SR OS nodes configured to stream syslog to EDA</p>
</li>
<li><p>EDA rulebook reacting to BGP session changes</p>
</li>
<li><p>NETCONF-based playbooks that automatically remediate those events on the SR OS nodes</p>
</li>
</ul>
<h2>Directory Structure</h2>
<p>Create the working directory and subdirectories first:</p>
<pre><code class="language-shell">mkdir -p sros-eda/{rulebooks,playbooks,inventory,logs}
cd sros-eda
</code></pre>
<h2>Step 1 — Python Virtual Environment</h2>
<p>Create an isolated environment so EDA dependencies do not conflict with system packages:</p>
<pre><code class="language-python">python3 -m venv venv
source venv/bin/activate
pip install --upgrade pip
</code></pre>
<p>Install Ansible EDA and its dependencies:</p>
<pre><code class="language-python">pip install ansible ansible-rulebook ansible-runner aiohttp ncclient
</code></pre>
<p>Install the required Ansible collections:</p>
<pre><code class="language-python">ansible-galaxy collection install ansible.eda
ansible-galaxy collection install nokia.sros
ansible-galaxy collection install ansible.netcommon
</code></pre>
<p>Verify everything installed correctly:</p>
<pre><code class="language-python">ansible-rulebook --version
ansible-galaxy collection list | grep -E "eda|sros|netcommon"
</code></pre>
<h2>Step 2 — SR OS Node Configuration</h2>
<p>Find your host IP first:</p>
<pre><code class="language-python">ip route get 8.8.8.8 | awk '{print $7; exit}'
</code></pre>
<p>Apply on SR OS nodes (replace &lt;HOST_IP&gt;):</p>
<pre><code class="language-python">/configure log syslog "eda" address &lt;HOST_IP&gt;
/configure log syslog "eda" severity warning
/configure log syslog "eda" port 5514
/configure log log-id "20" admin-state enable
/configure log log-id "20" description "EDA event stream"
/configure log log-id "20" source main true
/configure log log-id "20" destination syslog "eda"
 
</code></pre>
<h3>Verify syslog is functioning</h3>
<p>Start a listener on your host:</p>
<pre><code class="language-python">python3 -c "
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.bind(('0.0.0.0', 5514))
print('Listening on UDP 5514...')
while True:
    data, addr = s.recvfrom(1024)
    print(f'{addr[0]}: {data.decode()}')
"
</code></pre>
<p>Events from the SR OS node are being received:</p>
<pre><code class="language-python">Listening on UDP 5514...
172.20.20.13: &lt;186&gt;May 12 23:07:05 172.31.255.30 TMNX: 766 Base LOGGER-MAJOR-tmnxLogFileRollover-2008 [acct-log-id 9 file-id 19]:  Log file cf3:\act\act0919-20260512-223705.xml.gz on compact flash cf3 has been rolled over

172.20.20.13: &lt;187&gt;May 12 23:08:08 172.31.255.30 TMNX: 767 Base LOGGER-MINOR-tmnxLogFileDeleted-2009 [acct-log-id 9 file-id 19]:  Log file cf3:\act\act0919-20260512-200705.xml.gz on compact flash cf3 has been deleted

172.20.20.13: &lt;187&gt;May 12 23:08:08 172.31.255.30 TMNX: 768 Base LOGGER-MINOR-tmnxLogFileDeleted-2009 [acct-log-id 9 file-id 19]:  Log file cf3:\act\act0919-20260512-203705.xml.gz on compact flash cf3 has been deleted
</code></pre>
<h2>Step 3 — Ansible Inventory</h2>
<p>Create <code>inventory/hosts.yml</code> . This connects to SR OS via NETCONF using the nokia.sros.md network OS:</p>
<pre><code class="language-yaml">---
all:
  vars:
    ansible_user: &lt;user-name&gt;
    ansible_password: &lt;password&gt;
    ansible_connection: ansible.netcommon.netconf
    ansible_network_os: nokia.sros.md
    ansible_netconf_port: 830
    ansible_netconf_username: &lt;user-name&gt;
    ansible_netconf_password: &lt;password&gt;

  children:
    sros_nodes:
      hosts:

        sros1:
          ansible_host: 172.20.20.13
          ansible_netconf_host: 172.20.20.13
          bgp_neighbor: "10.10.10.2"
          bgp_peer_as: 65000

        sros2:
          ansible_host: 172.20.20.14
          ansible_netconf_host: 172.20.20.14
          bgp_neighbor: "10.10.10.1"
          bgp_peer_as: 65000
</code></pre>
<h2>Step 4 — EDA Rulebooks</h2>
<p><strong>Rulebook 1 — BGP Events</strong></p>
<pre><code class="language-yaml">---
- name: "SR OS EDA: BGP and Interface Event Handler"
  hosts: sros_nodes
  sources:
    - ansible.eda.syslog:
        host: 0.0.0.0
        port: 5514

  rules:

    - name: BGP session down
      condition: &gt;
        event.message is search(
          "bgpBackwardTransNotification|bgpSessionDown|bgpPeerNotFound|ADMIN_SHUT|BGP-WARNING",
          ignorecase=true)
      throttle:
        once_within: 60 seconds
        group_by_attributes:
          - event.host
      actions:
        - run_playbook:
            name: playbooks/bgp_session_down.yml
            extra_vars:
              source_host: "{{ event.host }}"
              syslog_message: "{{ event.message }}"

    - name: BGP session up
      condition: &gt;
        event.message is search(
          "bgpEstablishedNotification|ESTABLISHED",
          ignorecase=true)
      actions:
        - debug:
            msg: "BGP UP on {{ event.host }} — no action required"

    - name: Catch all — print every event received
      condition: event.message is defined
      actions:
        - debug:
            msg: "EVENT from {{ event.host }} | MSG: {{ event.message }}"
</code></pre>
<h2>Step 5 — Playbooks</h2>
<p><strong>BGP Session down</strong></p>
<pre><code class="language-yaml">---
- name: BGP Session Down — Remediate via NETCONF
  hosts: "{{ source_host | default('sros_nodes') }}"
  gather_facts: false

  tasks:

    - name: Collect BGP neighbor state via NETCONF
      netconf_get:
        filter: |
          &lt;configure xmlns="urn:nokia.com:sros:ns:yang:sr:conf"&gt;
            &lt;router&gt;
              &lt;bgp&gt;
                &lt;neighbor&gt;
                  &lt;ip-address/&gt;
                  &lt;admin-state/&gt;
                &lt;/neighbor&gt;
              &lt;/bgp&gt;
            &lt;/router&gt;
          &lt;/configure&gt;
      register: bgp_state

    - name: Get current timestamp
      delegate_to: localhost
      command: date '+%Y-%m-%dT%H:%M:%S'
      register: timestamp

    - name: Log BGP down event
      delegate_to: localhost
      lineinfile:
        path: logs/bgp_events.log
        line: "[{{ timestamp.stdout }}] BGP DOWN on {{ inventory_hostname }} | {{ syslog_message | default('no message') }}"
        create: yes

    - name: Disable BGP neighbor
      ansible.netcommon.netconf_config:
        lock: never
        content: |
          &lt;config xmlns="urn:ietf:params:xml:ns:netconf:base:1.0"&gt;
           &lt;configure   xmlns="urn:nokia.com:sros:ns:yang:sr:conf"&gt;
             &lt;router&gt;
             &lt;router-name&gt;Base&lt;/router-name&gt;
              &lt;bgp&gt;
                &lt;neighbor&gt;
                  &lt;ip-address&gt;{{ bgp_neighbor }}&lt;/ip-address&gt;
                  &lt;admin-state&gt;disable&lt;/admin-state&gt;
                &lt;/neighbor&gt;
              &lt;/bgp&gt;
             &lt;/router&gt;
           &lt;/configure&gt;
          &lt;/config&gt;
      when: bgp_neighbor is defined
      ignore_errors: yes
      register: clear_result

    - name: Wait 3 seconds
      pause:
        seconds: 3

    - name: Enable BGP neighbor
      ansible.netcommon.netconf_config:
        lock: never
        content: |
          &lt;config xmlns="urn:ietf:params:xml:ns:netconf:base:1.0"&gt;
           &lt;configure   xmlns="urn:nokia.com:sros:ns:yang:sr:conf"&gt;
             &lt;router&gt;
             &lt;router-name&gt;Base&lt;/router-name&gt;
              &lt;bgp&gt;
                &lt;neighbor&gt;
                  &lt;ip-address&gt;{{ bgp_neighbor }}&lt;/ip-address&gt;
                  &lt;admin-state&gt;enable&lt;/admin-state&gt;
                &lt;/neighbor&gt;
              &lt;/bgp&gt;
             &lt;/router&gt;
           &lt;/configure&gt;
          &lt;/config&gt;
      when: bgp_neighbor is defined
      ignore_errors: yes
      register: clear_result

    - name: Wait for BGP to re-establish
      pause:
        seconds: 15

    - name: Log remediation result
      delegate_to: localhost
      lineinfile:
        path: logs/bgp_events.log
        line: "[{{ timestamp.stdout }}] BGP REMEDIATION COMPLETE on {{ inventory_hostname }} | neighbor {{ bgp_neighbor | default('unknown') }}"
        create: yes
</code></pre>
<h2>Step 6 — Running EDA</h2>
<p><strong>Terminal 1 — Start EDA</strong></p>
<pre><code class="language-yaml">cd sros-eda
source venv/bin/activate

ansible-rulebook \
--rulebook rulebooks/01_bgp_and_interface.yml \
--inventory inventory/hosts.yml \
--verbose 
</code></pre>
<p>EDA starts and listens on 0.0.0.0:5514. It is now waiting for syslog events from your SR OS nodes.</p>
<p><strong>Output :</strong></p>
<pre><code class="language-yaml">(venv) [kleburu@devplayground]$ ansible-rulebook   --rulebook rulebooks/01_bgp_and_interface.yml   --inventory inventory/hosts.yml --verbose
2026-05-13 12:05:43,531 - ansible_rulebook.app - INFO - Starting sources
2026-05-13 12:05:43,531 - ansible_rulebook.app - INFO - Starting rules
2026-05-13 12:05:43,532 - drools.ruleset - INFO - Using jar: /labs/kleburu/python-projects/Ansible_Automation/sros-eda/venv/lib/python3.9/site-packages/drools/jars/drools-ansible-rulebook-integration-runtime-1.0.11-SNAPSHOT.jar
2026-05-13 12:05:44 171 [main] INFO org.drools.ansible.rulebook.integration.api.rulesengine.MemoryMonitorUtil - Memory occupation threshold set to 90%
2026-05-13 12:05:44 172 [main] INFO org.drools.ansible.rulebook.integration.api.rulesengine.MemoryMonitorUtil - Memory check event count threshold set to 64
2026-05-13 12:05:44 172 [main] INFO org.drools.ansible.rulebook.integration.api.rulesengine.MemoryMonitorUtil - Exit above memory occupation threshold set to false
2026-05-13 12:05:44 177 [main] INFO org.drools.ansible.rulebook.integration.api.rulesengine.AbstractRulesEvaluator - Start automatic pseudo clock with a tick every 100 milliseconds
2026-05-13 12:05:44,199 - ansible_rulebook.engine - INFO - load source ansible.eda.syslog
2026-05-13 12:05:44,201 - ansible_rulebook.engine - INFO - loading source filter eda.builtin.insert_meta_info
Syslog listener started on 0.0.0.0:5514
2026-05-13 12:05:44,519 - ansible_rulebook.engine - INFO - Waiting for all ruleset tasks to end
2026-05-13 12:05:44,520 - ansible_rulebook.rule_set_runner - INFO - Waiting for actions on events from SR OS EDA: BGP and Interface Event Handler
2026-05-13 12:05:44,520 - ansible_rulebook.rule_set_runner - INFO - Waiting for events, ruleset: SR OS EDA: BGP and Interface Event Handler
2026-05-13 12:05:44 520 [drools-async-evaluator-thread] INFO org.drools.ansible.rulebook.integration.api.io.RuleExecutorChannel - Async channel connected
</code></pre>
<p><strong>Terminal 2 — Test BGP event</strong></p>
<p>On the SR OS node:</p>
<pre><code class="language-yaml">/configure router bgp neighbor 10.10.10.2 admin-state disable
commit
</code></pre>
<p>EDA catches the syslog within 1-2 seconds and fires <code>bgp_session_down.yml</code>. Re-enable after the test:</p>
<p><strong>Output:</strong></p>
<pre><code class="language-yaml">PLAY [BGP Session Down — Remediate via NETCONF] ********************************

TASK [Collect BGP neighbor state via NETCONF] **********************************
ok: [sros1]

TASK [Get current timestamp] ***************************************************
changed: [sros1 -&gt; localhost]

TASK [Log BGP down event] ******************************************************
changed: [sros1 -&gt; localhost]

TASK [Disable BGP neighbor] ****************************************************
ok: [sros1]

TASK [Wait 3 seconds] **********************************************************
Pausing for 3 seconds
(ctrl+C then 'C' = continue early, ctrl+C then 'A' = abort)
ok: [sros1]

TASK [Enable BGP neighbor] *****************************************************
changed: [sros1]

TASK [Wait for BGP to re-establish] ********************************************
Pausing for 15 seconds
(ctrl+C then 'C' = continue early, ctrl+C then 'A' = abort)
ok: [sros1]

TASK [Log remediation result] **************************************************
changed: [sros1 -&gt; localhost]

PLAY RECAP *********************************************************************
sros1                      : ok=8    changed=4    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
</code></pre>
<h2>Quick Reference</h2>
<table>
<thead>
<tr>
<th>Command</th>
<th>Purpose</th>
</tr>
</thead>
<tbody><tr>
<td>source venv/bin/activate</td>
<td>Activate the virtual environment</td>
</tr>
<tr>
<td>ansible-rulebook \ --rulebook rulebooks/01_bgp_and_interface.yml \ --inventory inventory/hosts.yml \ --verbose</td>
<td>start EDA</td>
</tr>
<tr>
<td>tail -f logs/bgp_events.log</td>
<td>Watch BGP event log</td>
</tr>
<tr>
<td>show log syslog "name"</td>
<td>Verify syslog log-id on SR OS</td>
</tr>
<tr>
<td>show system netconf</td>
<td>Verify NETCONF on SR OS</td>
</tr>
<tr>
<td>show router bgp summary</td>
<td>Check BGP state on SR OS</td>
</tr>
</tbody></table>
<h1>The Bottom Line</h1>
<p>Event-driven automation is not optional anymore. Networks are highly dynamic, operators are limited, and the delay between events and responses is too expensive to rely on reactive, manual processes.</p>
<p>Ansible EDA secures its position in this landscape not due to its power, but because of its accessibility. For the vast majority of network automation teams that already have Ansible in their environment, EDA is the shortest path from a polling-based world to a truly event-driven one.</p>
<blockquote>
<p>Start with syslog. React to BGP events and interface flaps. Watch a playbook fire in under two seconds after a real network event. Then ask yourself what else you want to connect to it. The answer is usually: everything.</p>
</blockquote>
<h2>Further readings</h2>
<ul>
<li><p>Ansible EDA Documentation: <strong>ansible.readthedocs.io/projects/rulebook</strong></p>
</li>
<li><p>ansible-rulebook on GitHub: <strong>github.com/ansible/ansible-rulebook</strong></p>
</li>
<li><p>nokia.sros Ansible Collection: <strong>galaxy.ansible.com/nokia/sros</strong></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Version Control for Network Configurations]]></title><description><![CDATA[Network configurations change constantly — interfaces go up and down, routes shift, software gets upgraded. Without version control, there's no record of what changed, when, or by whom. Git addresses ]]></description><link>https://codednetwork.com/version-control-for-network-configurations</link><guid isPermaLink="true">https://codednetwork.com/version-control-for-network-configurations</guid><category><![CDATA[YAML]]></category><category><![CDATA[Python]]></category><category><![CDATA[Git]]></category><category><![CDATA[GitLab]]></category><category><![CDATA[gitlab-runner]]></category><category><![CDATA[ci-cd]]></category><category><![CDATA[CI/CD pipelines]]></category><dc:creator><![CDATA[Kgosi Leburu]]></dc:creator><pubDate>Tue, 05 May 2026 07:29:59 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/ab490d5f-d83e-470f-aa2c-88f001e6f2aa.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p>Network configurations change constantly — interfaces go up and down, routes shift, software gets upgraded. Without version control, there's no record of what changed, when, or by whom. Git addresses this for code. Here's how to extend that approach to your entire network.</p>
</blockquote>
<h1>Why version control for networks?</h1>
<p>Software developers have relied on version control for decades, tracking every line of code, attributing every change, and allowing mistakes to be undone with a single command. Network engineers, however, have traditionally lacked this convenience—configurations reside on devices, changes are made manually, and the audit trail depends on what you remember to document.</p>
<p>Applying Git to network configuration management changes this completely. When every config change is committed to a repository you get a full history of your network state, the ability to diff any two points in time, and the foundation for automation via CI/CD pipelines.</p>
<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/5036d1a4-13b5-43bf-9a27-e7affdc44850.png" alt="" style="display:block;margin:0 auto" />

<h1>Git fundamentals for network engineers</h1>
<p>Git is a distributed version control system where each repository copy holds the complete history. You work locally, commit changes, and then push to a shared server, such as GitLab.</p>
<h2>Git Terminology</h2>
<p>Before moving forward, let's ensure we have a clear understanding of some key terminology.</p>
<p><em><strong>Repository</strong></em> : In Git, a repository refers to the database that holds all of a project's information, including files, metadata, and history. Here, "project" is used to denote any arbitrary collection of work</p>
<p><em><strong>Working directory</strong></em> : This is the directory where you, as a Git user, modify the files within the repository.</p>
<p><em><strong>Index</strong></em> : The index represents the repository’s directory structure and contents at a specific moment. It is a dynamic binary file managed by Git, which updates as you stage changes and commit them to the repository.</p>
<p><em><strong>Commit</strong></em> : A commit is an entry in the Git repository that records metadata for each change made. This metadata includes the author, the date of the commit, and a commit message, which describes the change introduced to the repository.</p>
<h2>Essential Git Commands</h2>
<table>
<thead>
<tr>
<th>Command</th>
<th>Detail</th>
</tr>
</thead>
<tbody><tr>
<td><strong>git init</strong></td>
<td>Start a new git repository in the current folder</td>
</tr>
<tr>
<td><strong>git clone</strong></td>
<td>Download a copy of a GitLab repository to your machine</td>
</tr>
<tr>
<td><strong>git status</strong></td>
<td>Show which files have changed since the last commit</td>
</tr>
<tr>
<td><strong>git add .</strong></td>
<td>Stage all changed files — prepare them for a commit</td>
</tr>
<tr>
<td><strong>git commit -m "msg"</strong></td>
<td>Save a named snapshot of all staged changes</td>
</tr>
<tr>
<td><strong>git push origin main</strong></td>
<td>Upload your commits to the remote GitLab repository</td>
</tr>
<tr>
<td><strong>git pull origin main</strong></td>
<td>Download and merge the latest changes from GitLab</td>
</tr>
<tr>
<td><strong>git log --oneline</strong></td>
<td>Show a compact history of all commits in the repo</td>
</tr>
<tr>
<td><strong>git diff</strong></td>
<td>Show exactly what lines changed in unstaged files</td>
</tr>
<tr>
<td><strong>git reset --hard</strong></td>
<td>Discard all local changes and return to last commit</td>
</tr>
</tbody></table>
<p>Here's the core workflow for managing network configuration files:</p>
<pre><code class="language-python"># Make a change to your router list
vim inventory.yml

# Stage the change
git add inventory.yml

# Save a named snapshot
git commit -m "Add router3 to lab inventory"

# Upload to GitLab — triggers pipeline automatically
git push origin main
</code></pre>
<h1>Automating Nokia SROS Backups with GitLab CI/CD and Python</h1>
<blockquote>
<p>What if your router configurations backed themselves up every night and flagged exactly what changed? Here is how I built a fully automated backup and inventory pipeline for Nokia SROS routers using GitLab CI/CD and Netmiko.</p>
</blockquote>
<h2>The problem</h2>
<p>Manual router backups are tasks everyone knows they should perform but often neglect. You might make a change late on a Friday, forget to save a copy, and weeks later find yourself looking at a configuration that seems unfamiliar, with no clue when or how it was altered.</p>
<p>Managing a lab environment running Nokia SR OS routers brought this problem into sharp focus. The goal was simple: automate both the backup and change detection so that nothing slips through the cracks, and store everything in version control so you always have a full audit trail.</p>
<h2>The solution</h2>
<h3>GitLab CI/CD pipelines</h3>
<p>GitLab is a web-based platform that hosts Git repositories and adds powerful automation on top. The <code>.gitlab-ci.yml</code> file in your repository defines a pipeline — a sequence of automated jobs that run whenever specific events occur.</p>
<pre><code class="language-python">#.gitlab-ci.yml snapshot 

backup-job: # job name
  stage: backup # which stage it belongs to
  variables:
    GIT_STRATEGY: fetch # how to get the repo code
  script:
    - python backup.py # your automation script
    - git add clab-backups/
    - git commit -m "router backup [ci skip]"   
    - git push origin HEAD:$CI_COMMIT_BRANCH -o ci.skip
  tags:
    - nokia-runner # which machine runs this
</code></pre>
<h3>Repository Structure</h3>
<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/e25f1595-c429-4cc6-8117-14acce72ea53.jpg" alt="" style="display:block;margin:0 auto" />

<div>
<div>💡</div>
<div><strong>Note</strong> : The lab environment is enabled by <em>Containerlab</em></div>
</div>

<h3>What this pipeline does ?</h3>
<p>This is a two-job GitLab CI/CD pipeline that runs on a schedule. One job gathers router inventory and identifies changes, while the other captures the complete configuration. All results are committed back to the same GitLab repository, providing a version-controlled history of your entire network state.</p>
<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/42d312fe-663e-4679-bec1-164033fd5273.jpg" alt="" style="display:block;margin:0 auto" />

<p><strong>inventory-job — smart change detection</strong></p>
<p>Connects to each router via SSH using Netmiko, executes show commands, and compares the output with the last saved report. A new file is created and committed to the repository only if changes are detected.</p>
<blockquote>
<p>Smart saving: if nothing changed between runs the job exits cleanly with no commit and no push. Your git history only grows when something actually happened on the network.</p>
</blockquote>
<p><strong>backup-job — full config capture</strong></p>
<p>Always runs on every pipeline. Captures the complete running configuration via <code>admin display-config</code>. Saves a timestamped file per router. Verifies completeness by checking for <code>exit all</code> at the end. Keeps the last three backups per router.</p>
<h3>The complete pipeline file</h3>
<pre><code class="language-yaml">image: python:3.9.21

variables:
  PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
  GIT_STRATEGY: fetch

cache:
  paths:
    - .cache/pip
    - venv/

stages:
  - inventory
  - backup

before_script:
  - python -V
  - python -m pip install --upgrade pip
  - python -m pip install virtualenv
  - python -m virtualenv venv
  - source venv/bin/activate
  - pip install -r requirements.txt
  - rm -fr .git/rebase-merge .git/rebase-apply
  - git config --global user.email "$GITLAB_USER_EMAIL"
  - git config --global user.name "$GITLAB_USER_ID"
  - git config --global pull.rebase false
  - git fetch origin
  - git reset --hard origin/$CI_COMMIT_BRANCH

inventory-job:
  stage: inventory
  variables:
    GIT_STRATEGY: fetch
  script:
    - python inventory.py
    - git add clab-inventory/
    - &gt;
      if ! git diff-index --quiet HEAD; then
        git commit -m "inventory update [ci skip]"
        git remote set-url --push origin "https://\(TOKEN_NAME:\)ACCESS_TOKEN@\(CI_SERVER_HOST/\)CI_PROJECT_PATH.git"
        git push origin HEAD:$CI_COMMIT_BRANCH -o ci.skip
      else
        echo "No inventory changes to commit"
      fi
  rules:
    - if: '$CI_PIPELINE_SOURCE == "push"'
      changes:
        - inventory.yml
    - if: '$CI_PIPELINE_SOURCE == "web"'
    - if: '$CI_PIPELINE_SOURCE == "schedule"'
  tags:
    - nokia-runner

backup-job:
  stage: backup
  variables:
    GIT_STRATEGY: fetch
  script:
    - python backup.py
    - git add clab-backups/
    - &gt;
      if ! git diff-index --quiet HEAD; then
        git commit -m "router backup [ci skip]"
        git remote set-url --push origin "https://\(TOKEN_NAME:\)ACCESS_TOKEN@\(CI_SERVER_HOST/\)CI_PROJECT_PATH.git"
        git push origin HEAD:$CI_COMMIT_BRANCH -o ci.skip
      else
        echo "No backup changes to commit"
      fi
  tags:
    - nokia-runner
</code></pre>
<h3>Expected Output</h3>
<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/abf686eb-2c51-4782-8f6b-02f6d3dcfc1a.jpg" alt="" style="display:block;margin:0 auto" />

<div>
<div>💡</div>
<div>When both jobs are successfully executed</div>
</div>

<p><strong>inventory-job log output</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/535fed4e-6bce-43cf-a434-4e7fe0b7f5ef.jpg" alt="" style="display:block;margin:0 auto" />

<p><strong>Created output files</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/eab63dc2-147b-4962-9d5c-861bef573c4e.jpg" alt="" style="display:block;margin:0 auto" />

<h2>Set it up from scratch</h2>
<ol>
<li><p><strong>Create Gitlab Project</strong> - Log into gitlab.com, click New Project, create a blank private project named router-backups.</p>
</li>
<li><p><strong>Add your files</strong> - Create .gitlab-ci.yml, backup.py, inventory.py, inventory.yml, and requirements.txt in the repository root.</p>
</li>
<li><p><strong>Set CI/CD variables</strong> - Go to Settings &gt; CI/CD &gt; Variables. Add ACCESS_TOKEN (Maintainer role), TOKEN_NAME, and ROUTER_PASSWORD (masked).</p>
</li>
<li><p><strong>Register a GitLab Runner</strong> - Install gitlab-runner on the Linux machine that can reach your routers. Register with shell executor and tag nokia-runner.</p>
</li>
<li><p><strong>Set up a schedule</strong> - Go to Build &gt; Schedules, create a schedule with cron 0 2 * * * to run daily at 2 AM.</p>
</li>
<li><p><strong>Run the first pipeline</strong> - Go to Build &gt; Pipelines, click Run pipeline. Both jobs execute and first backup files appear in clab-backups/.</p>
</li>
</ol>
<blockquote>
<p>Once the runner is registered and variables are set, the system is self-maintaining. It backs up your routers and records changes automatically — no manual steps needed.</p>
</blockquote>
<h1>Key lessons learned</h1>
<ul>
<li><p><strong>Use shell executor, not docker</strong> : Docker networking prevents the runner from reaching internal lab routers. Shell executor runs directly on the machine and has full network access to your SROS devices.</p>
</li>
<li><p><strong>environment no more is not optional</strong> : Nokia SR OS paging will silently truncate your backups. This must be the first command you send after connecting — before any <code>show commands</code> or <code>admin display-config</code>.</p>
</li>
<li><p><strong>Hard reset beats rebase in CI/CD</strong> : git reset --hard origin/main at the start of every job is more reliable than trying to rebase or merge diverged branches mid-pipeline.</p>
</li>
<li><p><strong>Project Access Token needs Maintainer role</strong> : A token with Guest role cannot push to the repo. Create a Project Access Token and explicitly set the role to Maintainer during creation.</p>
</li>
<li><p><strong>Filenames matter — check for hidden spaces</strong> : A leading space in <code>inventory.yml</code> caused hours of confusion. Always run ls -la on the runner to verify filenames have no invisible characters.</p>
</li>
<li><p><strong>send_command_timing for large output</strong> : For <code>admin display-config</code>, timing-based reading is more reliable than pattern matching on large outputs that take unpredictable time to stream from the router.</p>
</li>
</ul>
<div>
<div>💡</div>
<div>The pipeline now runs daily at 2 AM, backs up two Nokia SROS routers, and commits only when something actually changed — fully hands-free.</div>
</div>]]></content:encoded></item><item><title><![CDATA[Streaming Telemetry: Real Time Network Observability]]></title><description><![CDATA[Introduction
Modern networks generate enormous volumes of operational data every second — interface counters, CPU utilization, BGP session states, MPLS label information, and more. For years, network ]]></description><link>https://codednetwork.com/streaming-telemetry-real-time-network-observability</link><guid isPermaLink="true">https://codednetwork.com/streaming-telemetry-real-time-network-observability</guid><category><![CDATA[gnmi]]></category><category><![CDATA[gnmic]]></category><category><![CDATA[telemetry]]></category><category><![CDATA[#prometheus]]></category><category><![CDATA[Grafana]]></category><category><![CDATA[nokiasros]]></category><category><![CDATA[YAML]]></category><dc:creator><![CDATA[Kgosi Leburu]]></dc:creator><pubDate>Tue, 28 Apr 2026 07:31:06 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/48e8bdcd-5c0f-4185-8424-ae97039e34e3.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>Introduction</h1>
<p>Modern networks generate enormous volumes of operational data every second — interface counters, CPU utilization, BGP session states, MPLS label information, and more. For years, network teams relied on <strong>SNMP polling</strong> to collect this data, but polling has fundamental limitations: it is slow, CPU-intensive at scale, and blind to events happening between poll cycles.</p>
<p><strong>Streaming telemetry</strong> addresses this by reversing the model. Rather than a manager requesting data on a schedule, devices continuously push data whenever values change or at a set interval. This allows for sub-second observability, quicker fault detection, and scalable monitoring architectures.</p>
<p>In this post, we guide you through a comprehensive end-to-end setup using Prometheus as the metrics engine. We'll configure streaming telemetry on Nokia SR OS routers, expose it via GNMIC as a Prometheus exporter, scrape and store metrics in Prometheus, and visualize everything using Grafana.</p>
<h1>Traditional SNMP vs Streaming Telemetry</h1>
<h2>Comparison Matrix</h2>
<table>
<thead>
<tr>
<th>Dimension</th>
<th>SNMP (Traditional)</th>
<th>gNMI Streaming</th>
</tr>
</thead>
<tbody><tr>
<td>Collection Model</td>
<td>Poll-based (pull) : manager request data</td>
<td>Push-based : client streams data</td>
</tr>
<tr>
<td>Transport</td>
<td>UDP (v2c) or TCP (v3)</td>
<td>gRPC over HTTP/2 + TLS</td>
</tr>
<tr>
<td>Typical interval</td>
<td>5–15 minutes</td>
<td>1–10 seconds (or ON_CHANGE)</td>
</tr>
<tr>
<td>Fault detection</td>
<td>Up to one full poll cycle</td>
<td>Sub-second in ON_CHANGE mode</td>
</tr>
<tr>
<td>Security</td>
<td>SNMPv2c: community string (plaintext) or SNMPv3 via hashing and encryption</td>
<td>mTLS, token auth, RBAC</td>
</tr>
<tr>
<td>Data Modelling</td>
<td>MIB-based , vendor fragmented</td>
<td>YANG-based, standards-aligned</td>
</tr>
<tr>
<td>Encoding</td>
<td>ASN.1 (Abstract Syntax Notation) with Basic Encodin Rules (BER)</td>
<td>JSON_IETF, PROTObuf, BYTES</td>
</tr>
</tbody></table>
<h1>gRPC Protocol</h1>
<p>gRPC (general-purpose Remote Procedure Call) is a modern, open-source, high-performance framework for Remote Procedure Calls (RPC).</p>
<p>gRPC was originally developed by Google, which had been using a general-purpose RPC infrastructure called Stubby to connect its numerous microservices across data centers for over a decade. In March 2015, Google decided to create the next iteration of Stubby and release it as open source. This led to the development of gRPC, now widely adopted by various organizations to support applications ranging from microservices to the "last mile" of computing, including mobile, web, and Internet of Things.</p>
<p>In Nokia SR OS, this framework is used to implement the gRPC server, which can then be used for configuration management or telemetry. The gRPC transport service uses HTTP/2 bidirectional streaming between the gRPC client (the data collector) and the gRPC server (the SR OS device). A gRPC session is a single connection from the gRPC client to the gRPC server over a TCP or TLS port. The gRPC service runs on port 57400 by default.</p>
<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/f75da4db-12eb-4b1d-b890-60e5c7094d54.jpg" alt="" style="display:block;margin:0 auto" />

<p><em>Protocol Stack</em></p>
<h2>Transport Layer Security (TLS)</h2>
<p>The gRPC server on the SR OS can operate in the following modes:</p>
<p>• without TLS encryption</p>
<p>• with TLS encryption</p>
<p>Enabling TLS encryption enhances security. Without it, gRPC messages are transmitted unencrypted, making usernames and passwords visible if packets are intercepted. In general, only server-side authentication is used, however, TLS does allow this to be extended to two-way authentication where the client certificate is also authenticated.</p>
<div>
<div>💡</div>
<div><strong>Note</strong> : Examples provided will be based on unsecured mode since testing occurs in a lab environment. It is recommended to enable TLS in production environments.</div>
</div>

<h1>gNMI Service</h1>
<p>gNMI (gRPC Network Management Interface) is a unified management protocol for streaming telemetry and configuration management, utilizing the open-source gRPC framework. It is an initiative of the OpenConfig consortium, a group of network operators led by Google. Nokia SR OS supports both OpenConfig and Nokia-native YANG models.</p>
<h2>gNMI Operations</h2>
<table>
<thead>
<tr>
<th>RPC</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td>Capability</td>
<td>Retrieve the set of capabilities that is supported by the server. This allows the client to validate the service version that is implemented and retrieve the set of models that the server supports</td>
</tr>
<tr>
<td>Get/Set</td>
<td>Information is retrieved from the NE using GET RPC messages, which consist of ‟<strong>GetRequest</strong>” and ‟<strong>GetResponse</strong>” messages. With Set, the data state on the server is modified. The paths to be changed, along with the new values the client wants to apply, are specified.</td>
</tr>
<tr>
<td>Subscribe</td>
<td>The gRPC client initiates a subscription by sending a subscribe RPC that contains a "<strong>SubscribeRequest</strong>" message to the gRPC server. The subscription contains one or more paths detailing the required data, together with a specified subscription mode</td>
</tr>
<tr>
<td>Publish</td>
<td>With dial-out telemetry, where the Nokia SR OS node is the gRPC client instead of the gRPC server, the SR OS node sends a Publish RPC with a ‟<strong>SubscribeResponse</strong>” message to the gRPC server</td>
</tr>
</tbody></table>
<h1>gRPC Telemetry Sessions</h1>
<p>gRPC telemetry sessions can be initiated using Dial-in and Dial-out modes</p>
<h2>Dial-in Telemetry</h2>
<p>When the data collector starts the gRPC connection, the Nokia SR OS node acts as the gRPC server, and the collector becomes the client. This is known as dial-in telemetry, where the Nokia SR OS node pushes data to the collector.</p>
<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/330858ca-4fb0-4201-9bd1-580605ae6dfe.jpg" alt="" style="display:block;margin:0 auto" />

<p><em>Telemetry Dial-in : Dynamic Subscriptions</em></p>
<h2>Dial-Out Telemetry</h2>
<p>When the Nokia SR OS node initiates the gRPC connection, the Nokia SR OS node assumes the role of the gRPC client. This is referred to as dial-out telemetry.</p>
<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/f7d01ced-43e4-4e33-8ab8-6f4ec871756c.jpg" alt="" style="display:block;margin:0 auto" />

<p><em>Telemetry Dial-out: Persistent Subscriptions</em></p>
<h1>Using gNMIc with Nokia SR OS</h1>
<p><strong>gNMIc</strong> : is a gNMI CLI client that provides full support for Capabilities, Get, Set and Subscribe RPCs with collector capabilities. Developed and maintained by Nokia.</p>
<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/0ea6cb40-7a63-470a-8b5d-85f072f44f63.jpg" alt="" style="display:block;margin:0 auto" />

<h2>gRPC Nokia SR OS server</h2>
<pre><code class="language-python"># enable model-driven mode 

configure system management-interface configuration-mode model-driven

# select user and permissions 

[gl:/configure system security user-params local-user user "admin"]
access {
        grpc true
    }
    console {
        member ["administrative"]
    }

# Enable unsecured gRPC 

[gl:/configure system grpc]
    admin-state enable
    allow-unsecure-connection
    rib-api {
        admin-state enable
    }
</code></pre>
<h2>gNMIc Install</h2>
<pre><code class="language-python"># To download &amp; install the latest release the following automated installation script can be used

bash -c "$(curl -sL https://get-gnmic.openconfig.net)"

# Check the version 

gnmic version
version : 0.45.0
 commit : d461eac0
   date : 2026-03-03T23:37:56Z
 gitURL : https://github.com/openconfig/gnmic
   docs : https://gnmic.openconfig.net
</code></pre>
<h2>Using gNMI services</h2>
<p>The following section offers practical examples of using gNMIc for gNMI operations. User documentation is available at <a href="https://gnmic.openconfig.net/">gnmic.openconfig.net</a> . It is well-structured and maintained, as well as easy to use.</p>
<h3>Capabilities RPC</h3>
<p>The simplest gNMI operation is the Capabilities RPC, which involves a Capability Request initiated by the client, followed by a Capability Response from the gRPC server detailing the supported gNMI version, YANG models, and gRPC encodings</p>
<pre><code class="language-python">gnmic -a 172.20.20.13:57400 -u admin -p codeNetwk@123! --insecure capabilities

gNMI version: 0.8.0
supported models:
  - nokia-conf, Nokia, 24.10.R5
  - nokia-state, Nokia, 24.10.R5
  - nokia-li-state, Nokia, 24.10.R5
  - nokia-sr-openconfig-aaa-augments, Nokia, 24.10.R5
  - nokia-sr-openconfig-dev-examples-augments, Nokia, 24.10.R5
  - nokia-sr-openconfig-if-ethernet-augments, Nokia, 24.10.R5
  - nokia-sr-openconfig-if-ip-augments, Nokia, 24.10.R5
  - nokia-sr-openconfig-mpls-augments, Nokia, 24.10.R5
  - nokia-sr-openconfig-system-terminal-deviations, Nokia, 24.10.R5
  - nokia-sr-openconfig-telemetry-deviations, Nokia, 24.10.R5
  - nokia-sr-openconfig-terminal-device-deviations, Nokia, 24.10.R5
  - nokia-sr-openconfig-vlan-deviations, Nokia, 24.10.R5
supported encodings:
  - JSON
  - BYTES
  - PROTO
  - JSON_IETF

................... [ Truncated Output ]
</code></pre>
<h3>Get RPC</h3>
<p>The Get RPC provides a mechanism for a client to request config or state data for a path or set of paths and for it to be returned by the target node</p>
<pre><code class="language-python">gnmic -a 172.20.20.13:57400 -u admin -p codeNetwk@123! --insecure \
      get --path /state/system/platform

[
  {
    "source": "172.20.20.13:57400",
    "timestamp": 1777325450020727215,
    "time": "2026-04-28T09:30:50.020727215+12:00",
    "updates": [
      {
        "Path": "state/system/platform",
        "values": {
          "state/system/platform": "7750 SR-1"
        }
      }
    ]
  }
]
</code></pre>
<h3>Set RPC</h3>
<p>The Set RPC is intended to allow for modifications to the configuration state of the target node .</p>
<p>gNMIc allows the user to execute Set RPCs for the update operation using in-line updates where the path and intended modifications are entered on the command line <code>--update-value</code></p>
<pre><code class="language-python">gnmic --config configs/config.yml set --update-path /configure/port[port-id=1/1/c1/1]/description --update-value gnmictest

2026/04/28 09:38:31.296832 [gnmic] version=0.45.0, commit=d461eac0, date=2026-03-03T23:37:56Z, gitURL=https://github.com/openconfig/gnmic, docs=https://gnmic.openconfig.net
2026/04/28 09:38:31.296873 [gnmic] using config file "configs/config.yml"
2026/04/28 09:38:31.297023 [gnmic] adding target {"name":"172.20.20.13:57400","address":"172.20.20.13:57400","username":"admin","password":"****","timeout":10000000000,"insecure":true,"skip-verify":false,"buffer-size":100,"retry-timer":10000000000,"log-tls-secret":false,"gzip":false,"token":""}
2026/04/28 09:38:31.297280 [gnmic] sending gNMI SetRequest: prefix='&lt;nil&gt;', delete='[]', replace='[]', update='[path:{elem:{name:"configure"}  elem:{name:"port"  key:{key:"port-id"  value:"1/1/c1/1"}}  elem:{name:"description"}}  val:{json_ietf_val:"\"gnmictest\""}]', extension='[]' to 172.20.20.13:57400
2026/04/28 09:38:31.297683 [gnmic] creating gRPC client for target "172.20.20.13:57400"
{
  "source": "172.20.20.13:57400",
  "timestamp": 1777325911161749222,
  "time": "2026-04-28T09:38:31.161749222+12:00",
  "results": [
    {
      "operation": "UPDATE",
      "path": "configure/port[port-id=1/1/c1/1]/description"
    }
  ]
}

# check the configured port in Nokia SROS 
[gl:/configure port 1/1/c1/1]
 admin-state enable
    description "gnmictest"
    ethernet {
        lldp {
            dest-mac nearest-bridge {
                notification true
                receive true
                transmit true
                tx-tlvs {
                    port-desc true
                    sys-name true
                    sys-desc true
                }
            }
        }
    }
</code></pre>
<div>
<div>💡</div>
<div><strong>Note</strong>: In this example, we use a <code>config.yml</code> file with logging enabled, along with the node IP information and credentials.</div>
</div>

<h3>Subscribe RPC</h3>
<p>When a client wants to receive specific state data, it initiates a subscription using the Subscribe RPC. This subscription includes one or more paths outlining the desired data, along with a chosen subscription mode. There are three modes that determine the duration of the subscription:</p>
<ul>
<li><p><strong>ONCE</strong>: specifies a subscription that provides data as a one-time response</p>
</li>
<li><p><strong>POLL</strong>: is a subscription that uses a stream to periodically request data</p>
</li>
<li><p><strong>STREAM</strong>: is a continuous subscription that streams data based on triggers defined by the subscription mode. This mode can be <code>ON_CHANGE,</code> <code>SAMPLE</code>, or <code>TARGET_DEFINED</code></p>
</li>
</ul>
<p><strong>ONCE</strong></p>
<p>A subscription operating in the ONCE mode dictates a single request/response exchange</p>
<pre><code class="language-python">gnmic -a 172.20.20.13:57400 -u admin -p codeNetwk@123! --insecure \
      sub --path "/state/system/memory-pools/summary" -- mode once

{
  "source": "172.20.20.13:57400",
  "subscription-name": "default-1777327406",
  "timestamp": 1777327406422019039,
  "time": "2026-04-28T10:03:26.422019039+12:00",
  "prefix": "state/system/memory-pools/summary",
  "updates": [
    {
      "Path": "current-total-size",
      "values": {
        "current-total-size": "2095054848"
      }
    },
    {
      "Path": "total-in-use",
      "values": {
        "total-in-use": "1898836352"
      }
    },
    {
      "Path": "available-memory",
      "values": {
        "available-memory": "1720713216"
      }
    }
  ]
}
{
  "sync-response": true
}
</code></pre>
<p><strong>STREAM</strong></p>
<p>Stream subscriptions are long-lived subscriptions which continue to transmit updates at the requested sample interval for the set of paths within the Subscribe Request indefinitely</p>
<pre><code class="language-python">gnmic -a 172.20.20.13:57400 -u admin -p codeNetwk@123! --insecure \
      sub --path "/state/port[port-id=1/1/c1/1]/statistics/in-packets" -- sample-interval 10s

{
  "source": "172.20.20.13:57400",
  "subscription-name": "default-1777327689",
  "timestamp": 1777327689262295519,
  "time": "2026-04-28T10:08:09.262295519+12:00",
  "prefix": "state/port[port-id=1/1/c1/1]/statistics",
  "updates": [
    {
      "Path": "in-packets",
      "values": {
        "in-packets": "14376890"
      }
    }
  ]
}
{
  "sync-response": true
}
{
  "source": "172.20.20.13:57400",
  "subscription-name": "default-1777327689",
  "timestamp": 1777327699260018112,
  "time": "2026-04-28T10:08:19.260018112+12:00",
  "prefix": "state/port[port-id=1/1/c1/1]/statistics",
  "updates": [
    {
      "Path": "in-packets",
      "values": {
        "in-packets": "14376916"
      }
    }
  ]
}
{
  "source": "172.20.20.13:57400",
  "subscription-name": "default-1777327689",
  "timestamp": 1777327709259895901,
  "time": "2026-04-28T10:08:29.259895901+12:00",
  "prefix": "state/port[port-id=1/1/c1/1]/statistics",
  "updates": [
    {
      "Path": "in-packets",
      "values": {
        "in-packets": "14376945"
      }
    }
  ]
}
</code></pre>
<h2>Dial-out Telemetry</h2>
<p>In general, the SR-OS node performs the role of a gRPC server responding to subscription requests from a gRPC client. However, it is possible for the SR-OS to perform the role of gRPC client, initiating gRPC connections and pushing data to a gRPC server using a Publish RPC. This is referred to as <em>dial-out telemetry</em></p>
<p>Dial-out subscriptions are configured on the router and are persistent. The configuration is defined with three constituent parts;</p>
<ul>
<li><p>A <em><strong>sensor group</strong></em> to define one or more schema paths to stream</p>
</li>
<li><p>A <em><strong>destination group</strong></em> specifying the destination address(es) and ports that the router uses to send telemetry data.</p>
</li>
<li><p><em><strong>Persistent subscriptions</strong></em> to associate a sensor group with a destination group</p>
</li>
</ul>
<p>The following shows an example of the SR-OS configuration for dial-out telemetry.</p>
<pre><code class="language-python">system {
        telemetry {
            destination-group "dialout" {
                allow-unsecure-connection
                destination 172.31.255.29 port 57400 {
                    router-instance "Base"
                }
            }
            persistent-subscriptions {
                subscription "dialout" {
                    admin-state enable
                    sensor-group "port-stats"
                    mode sample
                    destination-group "dialout"
                    encoding bytes
                }
            }
            sensor-groups {
                sensor-group "port-stats" {
                    path "/state/port[port-id=1/1/c1/1]/statistics/out-octets" { }
                }
            }
        }
	}

A:admin@PE1# /show system telemetry persistent subscription "dialout"

===============================================================
Telemetry persistent subscription
===============================================================
Subscription Name     : dialout
Administrative State  : Enabled
Operational State     : Up
Subscription Id       : 65
Description           : (Not Specified)
Sensor Group          : port-stats
Destination Group     : dialout
Path Mode             : sample
Sample Interval       : 10000 ms
Encoding              : json-ietf
===============================================================
</code></pre>
<h3>Setup gNMIc</h3>
<p>To make gNMIc simulate a gRPC server, use the listen argument along with an IP address and port to receive publish subscriptions.</p>
<pre><code class="language-python">gnmic listen -a 172.31.255.29:57400 --insecure
{
  "source": "172.20.20.13:58158",
  "system-name": "PE1",
  "subscription-name": "dialout",
  "timestamp": 1777328434091359417,
  "time": "2026-04-28T10:20:34.091359417+12:00",
  "prefix": "state/port[port-id=1/1/c1/1]/statistics",
  "updates": [
    {
      "Path": "out-octets",
      "values": {
        "out-octets": "5521927017"
      }
    }
  ]
}
{
  "source": "172.20.20.13:58158",
  "system-name": "PE1",
  "subscription-name": "dialout",
  "timestamp": 1777328444090433719,
  "time": "2026-04-28T10:20:44.090433719+12:00",
  "prefix": "state/port[port-id=1/1/c1/1]/statistics",
  "updates": [
    {
      "Path": "out-octets",
      "values": {
        "out-octets": "5521939044"
      }
    }
  ]
}
</code></pre>
<h1>The GPG Stack Architecture</h1>
<p>The GPG Stack (GNMIC · Prometheus · Grafana) serves as a cloud-native observability pipeline for network telemetry. GNMIC connects the gNMI push model with Prometheus's pull-based scrape model.</p>
<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/ee58d3f9-4150-4bc0-9155-337c4b0b6272.jpg" alt="" style="display:block;margin:0 auto" />

<p><em>Telemetry Stack</em></p>
<h2>GNMIC Config — Prometheus Output Mode</h2>
<pre><code class="language-python">subscriptions:
  port_stats_nokia:
    paths:
      - "/state/port[port-id=*]/statistics/in-packets"
      - "/state/port[port-id=*]/statistics/out-packets"
      - "/state/port[port-id=*]/statistics/in-octets"
      - "/state/port[port-id=*]/statistics/out-octets"
    mode: stream
    stream-mode: sample
    sample-interval: 10s
    encoding: json_ietf
  service_state:
    paths:
      - "/state/service/vprn[service-name=*]/oper-state"
    mode: once
  system_facts:
    paths:
      - "configure/system/name"
      - "state/system/version"
    mode: once
  system_resources:
    paths:
      - "/state/system/memory-pools/summary"
      - "/state/system/cpu[sample-period=60]/summary"
    mode: stream
    stream-mode: sample
    sample-interval: 10s
    encoding: json_ietf
targets:
      172.20.20.13:

outputs:
  prometheus-out:
    type: prometheus
    listen: ":9804"
    path: /metrics
    expiration: 10m
    metric-prefix: sros_
</code></pre>
<h3>Run gNMIc</h3>
<pre><code class="language-python">gnmic --config configs/subscriptions.yml sub --name port_stats_nokia

# Confirm if metrics are being exposed to Prometheus 
curl http://localhost:9804/metrics | head -20
sros__state_port_statistics_in_octets{port_port_id="1/1/c1",source="172.20.20.13",subscription_name="port_stats_nokia"} 0
sros__state_port_statistics_in_octets{port_port_id="1/1/c1/1",source="172.20.20.13",subscription_name="port_stats_nokia"} 5.510908681e+09
sros__state_port_statistics_in_octets{port_port_id="1/1/c2/1",source="172.20.20.13",subscription_name="port_stats_nokia"} 8.342930752e+09
sros__state_port_statistics_in_octets{port_port_id="1/1/c3/1",source="172.20.20.13",subscription_name="port_stats_nokia"} 149554
</code></pre>
<h2>Prometheus Configuration</h2>
<p>Install <a href="https://prometheus.io/docs/prometheus/latest/installation/">Prometheus</a></p>
<p><code>prometheus.yml</code></p>
<pre><code class="language-python">global:
  scrape_interval: 15s

scrape_configs:
  - job_name: 'gnmic'
    static_configs:
      - targets: ['localhost:9804']
</code></pre>
<h3>Access Prometheus</h3>
<pre><code class="language-python">http://localhost:9090

#If running on a VM (like your Rocky Linux setup)
http://&lt;VM-IP&gt;:9090
</code></pre>
<h3>Test Metrics in Prometheus</h3>
<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/0509f7d2-3fbd-457d-ad81-8ca0f52af3af.jpg" alt="" style="display:block;margin:0 auto" />

<p><em>sros_state_port_statistics are accessible in Prometheus</em></p>
<h2>Grafana Configuration</h2>
<p>Install <a href="https://grafana.com/docs/grafana/latest/setup-grafana/installation/">Grafana</a></p>
<h3>Access Grafana</h3>
<pre><code class="language-python">http://&lt;your-ip&gt;:3000
</code></pre>
<ul>
<li><p>Username: <code>admin</code></p>
</li>
<li><p>Password: <code>admin</code></p>
</li>
</ul>
<h3>Add Prometheus to Grafana</h3>
<p>In Grafana:</p>
<ul>
<li><p>Go to <strong>Settings → Data Sources</strong></p>
</li>
<li><p>Add <strong>Prometheus</strong></p>
</li>
<li><p>URL:</p>
</li>
</ul>
<pre><code class="language-python">http://&lt;your-host-ip&gt;:9090
</code></pre>
<h3>Build Your First Dashboard</h3>
<p>Here's a basic dashboard I've created, named "<strong>Nokia SR OS Port Telemetry</strong>" using a simple PromQL query: <code>rate(sros__state_port_statistics_in_octets[5m]) * 8</code>, which measures bits per second.</p>
<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/f5652270-1011-4ec6-928f-5c7c43164e4b.jpg" alt="" style="display:block;margin:0 auto" />

<h1>Conclusion</h1>
<p>Streaming telemetry is transforming the way modern networks are monitored and operated. Unlike traditional polling methods such as SNMP, telemetry provides <strong>continuous, near real-time visibility</strong> into device performance, interface utilization, routing behavior, and system health. This shift allows network engineers to move from reactive troubleshooting to proactive operations.</p>
<p>By combining <strong>gnmic</strong>, <strong>Prometheus</strong>, and <strong>Grafana</strong>, organizations can build a scalable observability platform capable of collecting high-frequency metrics and converting them into meaningful dashboards, alerts, and operational insights. Whether monitoring a single <strong>Nokia SR OS</strong> router or a multi-vendor backbone, telemetry enables faster fault detection, capacity planning, and service assurance.</p>
<p>For network automation teams, streaming telemetry is more than a monitoring tool—it is a foundation for <strong>closed-loop automation</strong>, where network state can automatically trigger intelligent actions. As networks continue to grow in speed, complexity, and business importance, telemetry becomes essential rather than optional.</p>
]]></content:encoded></item><item><title><![CDATA[Why Nornir matters after you have met Ansible ? ]]></title><description><![CDATA[Ansible is brilliant. It lowered the barrier to network automation so dramatically that engineers who had never touched Python could automate hundreds of devices overnight. If Ansible solved your prob]]></description><link>https://codednetwork.com/why-nornir-matters-after-you-have-met-ansible</link><guid isPermaLink="true">https://codednetwork.com/why-nornir-matters-after-you-have-met-ansible</guid><category><![CDATA[#Nornir]]></category><category><![CDATA[NetworkAutomation]]></category><category><![CDATA[Python]]></category><category><![CDATA[Python 3]]></category><category><![CDATA[Netmiko]]></category><category><![CDATA[netbox]]></category><category><![CDATA[YAML]]></category><category><![CDATA[nornir-netmiko]]></category><dc:creator><![CDATA[Kgosi Leburu]]></dc:creator><pubDate>Tue, 14 Apr 2026 07:19:03 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/6783b366-ad22-4e9a-8d76-6440b31f4cc2.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Ansible is brilliant. It lowered the barrier to network automation so dramatically that engineers who had never touched Python could automate hundreds of devices overnight. If Ansible solved your problem, keep using it. But if you've ever stared at a complex Ansible playbook and thought 'I could do this faster in a Python script' — <strong>Nornir</strong> is what you were looking for without knowing its name.</p>
<blockquote>
<p>"Nornir is not a replacement for Ansible. It's what happens when Python engineers decide they want the power of Ansible's inventory and parallelism without leaving Python."</p>
</blockquote>
<h1>The Ansible Model</h1>
<p>When you write Ansible, you're writing YAML that describes a desired state or sequence of tasks. Ansible translates that YAML into actions. This abstraction serves as both your strength and limitation.</p>
<p>Clean, declarative, and easily understood by your manager. However, if you need to query each device, parse responses, make conditional decisions based on model and current configuration, apply different templates for each device family, and log every step with timing data to a database, your playbook turns into a creative exercise involving set_fact, when conditions, and Jinja2 filters.</p>
<h1>Enter Nornir</h1>
<p>Nornir is a Python automation framework. It's not a DSL or a YAML interpreter—it's a Python library. You write in Python. Nornir integrates Ansible's strengths in inventory management and parallel task execution into the Python environment, and then lets you take the lead.</p>
<h2>Head to head: where they differ</h2>
<table>
<thead>
<tr>
<th>Ansible</th>
<th>Nornir</th>
</tr>
</thead>
<tbody><tr>
<td>YAML-based DSL, low Python requirement</td>
<td>Pure Python, full language available</td>
</tr>
<tr>
<td>Agentless, SSH/API by default</td>
<td>Threaded concurrency (fast, lightweight)</td>
</tr>
<tr>
<td>Massive module library (Galaxy)</td>
<td>Plugin ecosystem (Netmiko, NAPALM, etc.)</td>
</tr>
<tr>
<td>Readable by non-developers</td>
<td>Requires Python comfort</td>
</tr>
<tr>
<td>Parallelism via forks (process-based)</td>
<td>Testable with pytest</td>
</tr>
<tr>
<td>Complex logic = complex YAML</td>
<td>No overhead — just a library</td>
</tr>
</tbody></table>
<h1>Nornir Framework Overview</h1>
<p>Unlike Ansible, which abstracts logic behind a DSL, Nornir remains in Python, offering the full capabilities of the language for data manipulation, error handling, testing, and integration with external systems. Its architecture is centered around three pillars: <strong>Inventory,</strong> <strong>Tasks</strong>, and <strong>Runners</strong>.</p>
<p><strong>Inventory</strong> : Defines the devices (hosts), their attributes, and group memberships. Pluggable — SimpleInventory, NetBox, Ansible, custom.</p>
<p><strong>Tasks</strong> : Pure Python functions that accept a Task object and run operations on a single host. Composable and independently testable (eg config push).</p>
<p><strong>Runners</strong> : Control how tasks are executed across the inventory. ThreadedRunner (default) runs tasks concurrently using a thread pool.</p>
<p><strong>Plugins</strong> : Connection drivers (Netmiko, NAPALM, Scrapli) and utility tools (print_result, write_file) extend core functionality.</p>
<p><strong>Results</strong> : AggregatedResult and MultiResult objects give full per-host, per-task outcome data — iterable, filterable Python objects.</p>
<h2>Inventory Management</h2>
<p>Nornir's inventory system is entirely pluggable. One of its standout features is how its inventory concept closely mirrors Ansible's. You have hosts, groups, and variables, and you can even utilize your existing Ansible inventory files with the <code>AnsibleInventory</code> plugin.</p>
<p>For lab and small-scale deployments, <code>SimpleInventory</code> reads from YAML files. For production at 100+ devices, integrating directly with <code>NetBox</code> gives you a single source of truth.</p>
<h3>SimpleInventory — file structure</h3>
<p><strong>File</strong> : <code>config.yaml</code></p>
<pre><code class="language-yaml">inventory:
  plugin: SimpleInventory
  options:
    host_file: inventory/hosts.yaml
    group_file: inventory/groups.yaml
    defaults_file: inventory/defaults.yml

runner:
  plugin: threaded
  options:
    num_workers: 10
</code></pre>
<div>
<div>💡</div>
<div>YAML configuration file is defined for Nornir settings. You could define these settings directly using a Python dictionary</div>
</div>

<p><strong>File</strong> : <code>inventory/groups.yaml</code></p>
<pre><code class="language-yaml">sros:
  platform: nokia_sros
  username: &lt;username&gt;
  password: &lt;password&gt;
  connection_options:
    netmiko:
      extras:
        secret: ""
        global_delay_factor: 2
</code></pre>
<div>
<div>💡</div>
<div>Nokia SROS does NOT <strong>use enable mode </strong>hence an "empty" secret is defined to overcome a nornir-netmiko issue</div>
</div>

<p><strong>File</strong> : <code>inventory/hosts.yaml</code></p>
<pre><code class="language-yaml">router1:
  hostname: 172.20.20.13
  groups:
    - sros

router2:
  hostname: 172.20.20.14
  groups:
    - sros
</code></pre>
<h3>Tasks, Runners &amp; Plugins</h3>
<p>Understanding the execution model is critical before scaling to a large number of devices. Each task is a Python function. The runner controls concurrency. Plugins provide the connection layer to devices.</p>
<p><strong>The plugin ecosystem</strong></p>
<p>Nornir itself is deliberately minimal — it handles inventory and task running. Everything else is a plugin. The main ones you'll use immediately:</p>
<table>
<thead>
<tr>
<th>Plugin</th>
<th>Use Case</th>
</tr>
</thead>
<tbody><tr>
<td>nornir-napalm</td>
<td>multi-vendor getters and config push</td>
</tr>
<tr>
<td>nornir-netmiko</td>
<td>Netmiko connection plugin (SSH)</td>
</tr>
<tr>
<td>nornir-utils</td>
<td>print_result, print_title helpers</td>
</tr>
<tr>
<td>nornir-scrapli</td>
<td>Scrapli async connections</td>
</tr>
<tr>
<td>nornir-netconf</td>
<td>NETCONF operations</td>
</tr>
<tr>
<td>nornir-ansible</td>
<td>run Ansible modules from Nornir tasks</td>
</tr>
</tbody></table>
<p>Yes — <code>nornir-ansible</code> is real. You can call Ansible modules from inside a Nornir task. The ecosystems aren't enemies.</p>
<p><strong>ThreadedRunner — concurrency model</strong></p>
<p>The <code>ThreadedRunner</code> utilizes Python's <code>concurrent.futures.ThreadPoolExecutor</code>. Each host receives a thread from the pool. With num_workers set to 50, Nornir can run 50 SSH sessions at once. For 100 devices, this results in two batches.</p>
<h1>Use Case : Nokia SROS NTP Deployment Script</h1>
<h2>Installing Nornir</h2>
<pre><code class="language-python">python -m venv .venv
source .venv/bin/activate
pip install nornir nornir-utils nornir-netmiko nornir-napalm
</code></pre>
<div>
<div>💡</div>
<div>A virtual environment is an <strong>isolated Python workspace</strong> where you can install packages <strong>without affecting the system-wide Python installation</strong></div>
</div>

<h2>The Complete Script</h2>
<p>Here is the full working script before we break it down line by line. Read it once, then follow the steps below to understand exactly what each part does.</p>
<pre><code class="language-python">from nornir import InitNornir
from nornir_netmiko.tasks import netmiko_send_config
from nornir_utils.plugins.functions import print_result
from nornir.core.task import Result
from netmiko import ConnectHandler
from datetime import datetime

nr = InitNornir(config_file="config.yaml")

def deploy_services(task):
    device = {
        "device_type": "nokia_sros",
        "host":        task.host.hostname,
        "username":    task.host.username,
        "password":    task.host.password,
        "secret":      "",
    }
    commands = [
        "configure",
        "    system",
        "        time",
        "            ntp",
        "                server 192.168.1.1",
        "                no shutdown",
        "            exit",
        "        exit",
        "    exit",
        "exit",
    ]
    with ConnectHandler(**device) as conn:
        output = conn.send_config_set(commands)
        print(f"[{task.host.name}] Output:\n{output}")
        verify = conn.send_command("show system ntp")
        print(f"[{task.host.name}] Verification:\n{verify}")
    return Result(host=task.host, result=output, changed=True)

start_time = datetime.now()
results = nr.run(task=deploy_services)
end_time = datetime.now()
print_result(results)
print(f"Execution Time: {end_time - start_time}")
</code></pre>
<h3>Step-by-step breakdown</h3>
<p>1. <strong>Import the Libraries</strong></p>
<pre><code class="language-python">from nornir import InitNornir
from nornir_netmiko.tasks import netmiko_send_config
from nornir_utils.plugins.functions import print_result
from nornir.core.task import Result
from netmiko import ConnectHandler
from datetime import datetime
</code></pre>
<p><code>InitNornir</code> loads the framework and inventory. <code>netmiko_send_config</code> is the Nornir plugin task for pushing config — imported but not used directly in this script (we bypass it with raw ConnectHandler instead, due to a <code>nornir-netmiko</code> bug with Nokia SROS <strong>24.10.R5</strong> discovered while testing). <code>print_result</code> gives a formatted per-host output table. <code>Result</code> is the object we return from our task to tell Nornir what happened. <code>ConnectHandler</code> is Netmiko's core SSH connection class. <code>datetime</code> is used to measure total execution time</p>
<p>2. <strong>Initialise Nornir from config file</strong></p>
<pre><code class="language-python">nr = InitNornir(config_file="config.yaml")
</code></pre>
<p>This single line reads <code>config.yaml</code> and builds the entire Nornir runtime: the inventory (hosts.yaml + groups.yaml), the runner (ThreadedRunner with 10 workers).</p>
<p>3. <strong>Define the task function</strong></p>
<pre><code class="language-python">def deploy_services(task):
</code></pre>
<p>This is a Nornir task — a plain Python function that accepts a single argument called task. Nornir calls this function once per host in your inventory, passing a Task object. Nornir uses threads, this function runs simultaneously across all hosts.</p>
<p>4. <strong>Build the device connection dictionary</strong></p>
<pre><code class="language-python">device = {
    "device_type": "nokia_sros",
    "host":        task.host.hostname,
    "username":    task.host.username,
    "password":    task.host.password,
    "secret":      "",
}
</code></pre>
<p>This dictionary is passed directly to Netmiko's ConnectHandler. device_type tells Netmiko which SSH driver to use — nokia_sros loads the Nokia-specific handler that understands SROS prompts and CLI behaviour.</p>
<p>5. <strong>Define the CLI commands</strong></p>
<pre><code class="language-python">commands = [
    "configure",
    "    system",
    "        time",
    "            ntp",
    "                server 192.168.1.1",
    "                no shutdown",
    "            exit",
    "        exit",
    "    exit",
    "exit",
]
</code></pre>
<p>In this example Nokia SROS Classic CLI uses a hierarchical config tree. You must navigate down into each level with the context keyword (configure, system, time, ntp) and then back out with exit at each level.</p>
<p>6. <strong>Open the SSH connection with ConnectHandler</strong></p>
<pre><code class="language-python">with ConnectHandler(**device) as conn:
</code></pre>
<p>ConnectHandler(**device) unpacks the device dictionary as keyword arguments and opens an SSH session to the router. Using <code>ConnectHandler</code> directly (rather than through nornir-netmiko) bypasses the plugin bug where extras from the Nornir inventory were not being passed through to <code>ConnectHandler</code>.</p>
<p>7. <strong>Send the configuration commands</strong></p>
<pre><code class="language-python">output = conn.send_config_set(commands)
print(f"[{task.host.name}] Output:\n{output}")
</code></pre>
<p><code>send_config_set()</code> sends each command in the list one by one over SSH and captures the router's response after each command. For SROS it handles the prompt detection automatically — it waits for the next prompt before sending the next command. The print statement immediately shows what the router returned</p>
<p>8. <strong>Verify the configuration was applied</strong></p>
<pre><code class="language-python">verify = conn.send_command("show system ntp")
print(f"[{task.host.name}] Verification:\n{verify}")
</code></pre>
<p><code>send_command()</code> sends a single show command and returns the output as a string. Running show system ntp immediately after the config push confirms the NTP server is now present in the running config</p>
<p>9. <strong>Return a Result object to Nornir</strong></p>
<pre><code class="language-python">return Result(host=task.host, result=output, changed=True)
</code></pre>
<p>Every Nornir task should return a Result object. <code>host=task.host</code> tells Nornir which device this result belongs to. <code>result=output</code> stores the raw command output in the result</p>
<p>10. <strong>Run the task across all hosts and measure time</strong></p>
<pre><code class="language-python">start_time = datetime.now()
results = nr.run(task=deploy_services)
end_time = datetime.now()
</code></pre>
<p><code>datetime.now()</code> captures the wall-clock time before and after execution</p>
<p>11. <strong>Print results and execution time</strong></p>
<pre><code class="language-python">print_result(results)
print(f"Execution Time: {end_time - start_time}")
</code></pre>
<p><code>print_result()</code> from nornir_utils formats the AggregatedResult into the familiar Nornir output tree</p>
<h3>Execution Output</h3>
<pre><code class="language-python">[router1] Output:
configure
A:PE1&gt;config# system
A:PE1&gt;config&gt;system# time
A:PE1&gt;config&gt;system&gt;time# ntp
A:PE1&gt;config&gt;system&gt;time&gt;ntp# server 192.168.1.1
*A:PE1&gt;config&gt;system&gt;time&gt;ntp# no shutdown
*A:PE1&gt;config&gt;system&gt;time&gt;ntp# exit
*A:PE1&gt;config&gt;system&gt;time# exit
*A:PE1&gt;config&gt;system# exit
*A:PE1&gt;config# exit
*A:PE1# exit all
*A:PE1#
[router1] Verification:

===============================================================
NTP Status
===============================================================
Configured         : Yes                Stratum              : -
Admin Status       : up                 Oper Status          : up
Server Enabled     : No                 Server Authenticate  : No
Clock Source       : none
Auth Check         : Yes
Auth Keychain      :
Current Date &amp; Time: 2026/04/14 00:14:27 UTC
===============================================================

deploy_services************************************************
* router1 ** changed : True ***********************************
vvvv deploy_services ** changed : True vvvvvvvvvvvvvvvvvvv INFO

configure
A:PE1&gt;config# system
A:PE1&gt;config&gt;system# time
A:PE1&gt;config&gt;system&gt;time# ntp
A:PE1&gt;config&gt;system&gt;time&gt;ntp# server 192.168.1.1
*A:PE1&gt;config&gt;system&gt;time&gt;ntp# no shutdown
*A:PE1&gt;config&gt;system&gt;time&gt;ntp# exit
*A:PE1&gt;config&gt;system&gt;time# exit
*A:PE1&gt;config&gt;system# exit
*A:PE1&gt;config# exit
*A:PE1# exit all
*A:PE1#

^^^^ END deploy_services ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
</code></pre>
<div>
<div>💡</div>
<div>This was tested on a Nokia SROS running 24.10.R4 release</div>
</div>

<h1>Conclusion</h1>
<p>We explored the fundamental Nornir concepts, such as creating an inventory, defining and executing tasks (with multithread execution), and handling the results. These are the essential elements for developing complex network automation tasks.</p>
<p>Nornir's true power lies in its extensibility through plug-ins. While using Python directly might seem daunting at first, gaining proficiency with it makes Nornir an excellent choice for complete control over your network automation tasks.</p>
<p>With <strong>Nornir</strong>, you unlock:</p>
<ul>
<li><p>Massive speed improvements</p>
</li>
<li><p>True parallel execution</p>
</li>
<li><p>Full Python flexibility</p>
</li>
</ul>
<p>For large-scale <strong>Nokia SR OS</strong> environments, Nornir becomes a <strong>game-changer</strong> for automation efficiency.</p>
]]></content:encoded></item><item><title><![CDATA[Ansible for Network Automation ]]></title><description><![CDATA[Ansible, developed by Red Hat, is an open-source platform. Our focus is on Ansible Core, the open-source version. Red Hat also provides a commercial product, Ansible Tower, which enhances Ansible Core]]></description><link>https://codednetwork.com/ansible-for-network-automation</link><guid isPermaLink="true">https://codednetwork.com/ansible-for-network-automation</guid><category><![CDATA[YAML]]></category><category><![CDATA[ansible]]></category><category><![CDATA[ansible-playbook]]></category><category><![CDATA[ansible-module]]></category><category><![CDATA[NetworkAutomation]]></category><category><![CDATA[RHEL]]></category><dc:creator><![CDATA[Kgosi Leburu]]></dc:creator><pubDate>Tue, 07 Apr 2026 07:42:44 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/0ac1708a-2e61-4f15-ad25-ca091cfcbf96.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Ansible, developed by Red Hat, is an open-source platform. Our focus is on Ansible Core, the open-source version. Red Hat also provides a commercial product, Ansible Tower, which enhances Ansible Core with features such as Role-Based Access Control (RBAC), secure network credential storage, and a RESTful API, among others.</p>
<h1>Why Ansible for Network Automation ?</h1>
<p>Network engineers have long relied on manual CLI workflows: SSH into a device, run show commands, copy-paste output into a spreadsheet, make a change, repeat. This approach is slow, prone to errors, and unscalable when managing numerous routers across multiple vendors.</p>
<p>Ansible changes the game. It's agentless, uses SSH/NETCONF under the hood, and treats your network configuration as <strong>code</strong> — version-controlled, repeatable, and auditable. For a multivendor environment running Cisco IOS-XR, Juniper vMX, and Nokia SR OS, Ansible speaks all three dialects fluently.</p>
<h1>Ansible Architecture : The Core building blocks</h1>
<p>Here's a visual breakdown of Ansible's architecture:</p>
<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/675faa40-401f-4dc6-a9eb-85e675b970df.png" alt="" style="display:block;margin:0 auto" />

<h2>1. Inventory - Your network Source of Truth</h2>
<p>The inventory file informs Ansible about the devices that exist and how to access them. You can use either IP addresses or fully qualified domain names. In the upcoming example we’ll introduce how to create groups. We will organize devices into vendor groups for targeted plays.</p>
<p><strong>File</strong>: <code>inventory.yml</code></p>
<pre><code class="language-yaml">[cisco_iosxr]
rtr-cisco-01 ansible_host=172.20.20.15

[juniper_vmx]
rtr-junos-01 ansible_host=172.20.20.16

[nokia_sros]
rtr-nokia-01 ansible_host=172.20.20.13
rtr-nokia-02 ansible_host=172.20.20.14

[core_routers:children]
cisco_iosxr
juniper_vmx
nokia_sros
</code></pre>
<h2>2. Playbooks - Your automation Scripts</h2>
<p>The playbook is a file that contains your automation instructions. In other words, playbooks contain the individual tasks and workflows that you want to use to automate your network. The playbook is written in YAML and consists of one or more plays. Each play, in turn, includes one or more tasks.</p>
<p>Let’s take a look at an example playbook to better understand its structure</p>
<pre><code class="language-yaml">---
- name: Run Pre-Change Checks
  hosts: core_routers
  gather_facts: false
  tasks:
    - name: Include vendor-specific pre-check tasks
      include_tasks: "tasks/pre_check_{{ ansible_network_os }}.yml"
</code></pre>
<p>Our example playbook provides an overview of the structure to be used. You should already understand the basics of YAML and indentation. Ultimately, you need a YAML list of plays and a YAML list of tasks under the tasks key. Any slight indentation errors will result in error messages from Ansible when running your playbook, so precision is crucial.</p>
<h2>3. Modules - The Actions of Ansible</h2>
<p>Ansible modules perform a specific operation. For network automation, you'll use vendor-specific collections:</p>
<table>
<thead>
<tr>
<th>Vendor</th>
<th>Collection</th>
<th>Key Modules</th>
</tr>
</thead>
<tbody><tr>
<td>Cisco IOS-XR</td>
<td>cisco.iosxr</td>
<td>iosxr_command,iosxr_config, iosxr_facts</td>
</tr>
<tr>
<td>Juniper Junos</td>
<td>junipernetworks.junos</td>
<td>junos_command, junos_config, junos_facts</td>
</tr>
<tr>
<td>Nokia SROS</td>
<td>nokia.sros</td>
<td>sros_command, sros_config</td>
</tr>
</tbody></table>
<h3>connection types</h3>
<pre><code class="language-yaml"># group_vars/cisco_iosxr.yml
ansible_network_os: cisco.iosxr.iosxr
ansible_connection: ansible.netcommon.network_cli

# group_vars/juniper_vmx.yml
ansible_network_os: junipernetworks.junos.junos
ansible_connection: ansible.netcommon.netconf

# group_vars/nokia_sros.yml
ansible_network_os: nokia.sros
ansible_connection: ansible.netcommon.network_cli
</code></pre>
<h2>Variables and Vault Credentials</h2>
<p>Hardcoding passwords in playbooks is a career-limiting move. Use <strong>group_vars</strong> for non-sensitive data and Ansible Vault for <strong>secrets</strong>.</p>
<p>Since we are testing in a lab environment we are not using Ansible Vault but it is highly recommended in a Production environment.</p>
<h3>group_vars/nokia_sros.yml — Non-sensitive defaults</h3>
<pre><code class="language-yaml">ansible_network_os: nokia.sros
ansible_connection: ansible.netcommon.network_cli
ansible_user: "admin"
ansible_password: "{{ vault_ansible_password }}""
</code></pre>
<h3>Creating and using Ansible Vault</h3>
<pre><code class="language-yaml"># Create an encrypted secrets file
ansible-vault create group_vars/all/vault.yml

# Edit it later
ansible-vault edit group_vars/all/vault.yml
</code></pre>
<p>Inside the vault file :</p>
<pre><code class="language-yaml"># group_vars/all/vault.yml (encrypted at rest)
vault_ansible_password: "cOdeDNetw\(rk2#%\)"
</code></pre>
<p>Run playbooks with Vault :</p>
<pre><code class="language-yaml"># Prompt for vault password
ansible-playbook precheck.yml --ask-vault-pass
</code></pre>
<h1>Hands On - Multivendor Pre/Post Change Checks</h1>
<p>The scenario involves a maintenance window for BGP configuration changes across core routers. You need to capture the device state before and after the changes, then compare them for any differences.</p>
<table>
<thead>
<tr>
<th>Action</th>
<th>Ansible Command</th>
</tr>
</thead>
<tbody><tr>
<td>Run pre-checks</td>
<td>ansible-playbook -i inventory.yml precheck.yml</td>
</tr>
<tr>
<td>make changes</td>
<td>Manual or separate change playbook</td>
</tr>
<tr>
<td>Run post-checks</td>
<td>ansible-playbook -i inventory.yml postcheck.yml</td>
</tr>
<tr>
<td>Compare &amp; Validate</td>
<td>ansible-playbook -i inventory.yml compare.yml</td>
</tr>
</tbody></table>
<h2>Project Structure</h2>
<pre><code class="language-shell">pre-post-checks/
├── inventory.yml
├── precheck.yml
├── postcheck.yml
├── junos_checks.yml          
├── sros_checks.yml                           
├── reports/
└── group_vars/
</code></pre>
<div>
<div>💡</div>
<div>All the tasks are flattened into one directory. A separate <em>task </em>folder can be created</div>
</div>

<h3>Prerequisites</h3>
<pre><code class="language-python">pip install ansible
pip install ansible-pylibssh (optional
</code></pre>
<h3>Pre-check Playbook</h3>
<p><strong>File</strong> : <code>precheck.yml</code></p>
<pre><code class="language-yaml">- name: "PRE-CHANGE CHECKS"
  hosts: core_routers
  gather_facts: false
  vars:
    check_phase: "pre"
    timestamp: "{{ lookup('pipe', 'date +%Y%m%d_%H%M%S') }}"
    output_base: "{{ playbook_dir }}/reports"

  tasks:
    - name: Debug output path
      debug:
         msg: "Will create: reports/{{ inventory_hostname }}/{{ timestamp }}"
      delegate_to: localhost

    - name: Create output directory
      shell: mkdir -p "{{ output_base }}/{{ inventory_hostname }}/{{ timestamp }}"
      delegate_to: localhost

    - name: Set output_dir fact per host
      set_fact:
        output_dir: "{{ output_base }}/{{ inventory_hostname }}/{{ timestamp }}" 

    - name: Run Cisco IOS-XR checks
      include_tasks: cisco_checks.yml
      when: ansible_network_os == "cisco.iosxr.iosxr"

    - name: Run Juniper Junos checks
      include_tasks: junos_checks.yml
      when: ansible_network_os == "junipernetworks.junos.junos"

    - name: Run Nokia SR OS checks
      include_tasks: sros_checks.yml
      when: ansible_network_os == "sros"
</code></pre>
<p><strong>File</strong> : <code>cisco_checks.yml</code></p>
<pre><code class="language-yaml"> - name: "[Cisco] Gather BGP summary"
  cisco.iosxr.iosxr_command:
    commands:
      - show bgp ipv4 unicast summary
      - show bgp ipv6 unicast summary
  register: cisco_bgp_output

- name: "[Cisco] Gather interface status"
  cisco.iosxr.iosxr_command:
    commands:
      - show interfaces brief
      - show ipv4 interface brief
  register: cisco_intf_output

- name: "[Cisco] Gather routing table summary"
  cisco.iosxr.iosxr_command:
    commands:
      - show route summary
      - show route ipv6 summary
  register: cisco_route_output

- name: "[Cisco] Debug output_dir"
  debug:
    msg: "output_dir is: {{ output_dir }}"
  delegate_to: localhost

- name: "[Cisco] Save pre-check output to file"
  copy:
    content: |
      === CISCO IOS-XR PRE-CHECK: {{ inventory_hostname }} ===
      Timestamp: {{ timestamp }}

      --- BGP SUMMARY (IPv4) ---
      {{ cisco_bgp_output.stdout[0] }}

      --- BGP SUMMARY (IPv6) ---
      {{ cisco_bgp_output.stdout[1] }}

      --- INTERFACE STATUS ---
      {{ cisco_intf_output.stdout[0] }}

      --- ROUTE SUMMARY ---
      {{ cisco_route_output.stdout[0] }}
    dest: "{{ output_dir }}/pre_check.txt"
  delegate_to: localhost
</code></pre>
<p><strong>File</strong> : <code>junos_checks.yml</code></p>
<pre><code class="language-yaml">- name: "[Junos] Gather BGP summary (structured)"
  junipernetworks.junos.junos_command:
    commands:
      - show bgp summary
    display: xml   # NETCONF returns structured XML
  register: junos_bgp_output

- name: "[Junos] Gather interface status"
  junipernetworks.junos.junos_command:
    commands:
      - show interfaces terse
  register: junos_intf_output

- name: "[Junos] Gather routing table"
  junipernetworks.junos.junos_command:
    commands:
      - show route summary
  register: junos_route_output

- name: "[Junos] Extract BGP peer count using XPath"
  set_fact:
    bgp_peer_count: &gt;-
      {{ junos_bgp_output.output[0] | regex_findall('peer-count.*?&lt;/peer-count&gt;')
         | length }}

- name: "[Junos] Save pre-check output"
  copy:
    content: |
      === JUNIPER vMX PRE-CHECK: {{ inventory_hostname }} ===
      Timestamp: {{ timestamp }}

      --- BGP SUMMARY ---
      {{ junos_bgp_output.stdout[0] | default(junos_bgp_output.output[0]) }}

      --- INTERFACES ---
      {{ junos_intf_output.stdout[0] }}

      --- ROUTING TABLE SUMMARY ---
      {{ junos_route_output.stdout[0] }}
    dest: "{{ output_dir }}/pre_check.txt"
  delegate_to: localhost
</code></pre>
<p><strong>File</strong> : <code>sros_checks.yml</code></p>
<pre><code class="language-yaml">- name: "[Nokia] Gather BGP summary"
  community.network.sros_command:
    commands:
      - show router bgp summary
      - show router bgp summary family ipv6
  register: sros_bgp_output

- name: "[Nokia] Gather interface status"
  community.network.sros_command:
    commands:
      - show router interface
      - show port
  register: sros_intf_output

- name: "[Nokia] Gather routing table"
  community.network.sros_command:
    commands:
      - show router route-table summary
  register: sros_route_output

- name: "[Nokia] Save pre-check output"
  copy:
    content: |
      === NOKIA SR OS PRE-CHECK: {{ inventory_hostname }} ===
      Timestamp: {{ timestamp }}

      --- BGP SUMMARY (IPv4) ---
      {{ sros_bgp_output.stdout[0] }}

      --- BGP SUMMARY (IPv6) ---
      {{ sros_bgp_output.stdout[1] }}

      --- INTERFACES ---
      {{ sros_intf_output.stdout[0] }}

      --- ROUTE TABLE SUMMARY ---
      {{ sros_route_output.stdout[0] }}
    dest: "{{ output_dir }}/pre_check.txt"
  delegate_to: localhost
</code></pre>
<p><strong>File</strong> : <code>group_vars/cisco_ixr.yml</code></p>
<pre><code class="language-yaml">ansible_network_os: cisco.iosxr.iosxr
ansible_connection: ansible.netcommon.network_cli
ansible_user: "&lt;username&gt;"
ansible_password: "&lt;password&gt;"
</code></pre>
<div>
<div>💡</div>
<div>The <code>nokia sros</code> and<code> juniper</code> are similar to the above</div>
</div>

<h3>Run the Playbook</h3>
<p>Output :</p>
<pre><code class="language-python">ansible-playbook -i inventory.yml precheck.yml

PLAY [PRE-CHANGE CHECKS] ***************************************************************

TASK [Debug output path] ***************************************************************
ok: [rtr-cisco-01 -&gt; localhost] =&gt; {
    "msg": "Will create: reports/rtr-cisco-01/20260407_103940"
}
ok: [rtr-junos-01 -&gt; localhost] =&gt; {
    "msg": "Will create: reports/rtr-junos-01/20260407_103940"
}
ok: [rtr-nokia-01 -&gt; localhost] =&gt; {
    "msg": "Will create: reports/rtr-nokia-01/20260407_103940"
}
ok: [rtr-nokia-02 -&gt; localhost] =&gt; {
    "msg": "Will create: reports/rtr-nokia-02/20260407_103940"
}

TASK [Create output directory] ***************************************************************
changed: [rtr-nokia-02 -&gt; localhost]
changed: [rtr-nokia-01 -&gt; localhost]
changed: [rtr-cisco-01 -&gt; localhost]
changed: [rtr-junos-01 -&gt; localhost]

TASK [Set output_dir fact per host] ***************************************************************
ok: [rtr-junos-01]
ok: [rtr-cisco-01]
ok: [rtr-nokia-01]
ok: [rtr-nokia-02]

TASK [Run Cisco IOS-XR checks] ***************************************************************
skipping: [rtr-junos-01]
skipping: [rtr-nokia-01]
skipping: [rtr-nokia-02]
included: /labs/kleburu/python-projects/Ansible_Automation/multivendor-validation/pre-post-checks/cisco_checks.yml for rtr-cisco-01

TASK [[Cisco] Gather BGP summary] ***************************************************************
ok: [rtr-cisco-01]

TASK [[Cisco] Gather interface status] ***************************************************************
ok: [rtr-cisco-01]

TASK [[Cisco] Gather routing table summary] ***************************************************************
ok: [rtr-cisco-01]

TASK [[Cisco] Debug output_dir] ***************************************************************
ok: [rtr-cisco-01 -&gt; localhost] =&gt; {
    "msg": "output_dir is: /labs/kleburu/python-projects/Ansible_Automation/multivendor-validation/pre-post-checks/reports/rtr-cisco-01/20260407_103940"
}

TASK [[Cisco] Save pre-check output to file] ***************************************************************
changed: [rtr-cisco-01 -&gt; localhost]

TASK [Run Juniper Junos checks] ***************************************************************
skipping: [rtr-cisco-01]
skipping: [rtr-nokia-01]
skipping: [rtr-nokia-02]
included: /labs/kleburu/python-projects/Ansible_Automation/multivendor-validation/pre-post-checks/junos_checks.yml for rtr-junos-01

TASK [[Junos] Gather BGP summary (structured)] ***************************************************************
ok: [rtr-junos-01]

TASK [[Junos] Gather interface status] ***************************************************************
ok: [rtr-junos-01]

TASK [[Junos] Gather routing table] ***************************************************************
ok: [rtr-junos-01]

TASK [[Junos] Extract BGP peer count using XPath] ***************************************************************
ok: [rtr-junos-01]

TASK [[Junos] Save pre-check output] ***************************************************************
changed: [rtr-junos-01 -&gt; localhost]

TASK [Run Nokia SR OS checks] ***************************************************************
skipping: [rtr-cisco-01]
skipping: [rtr-junos-01]
included: /labs/kleburu/python-projects/Ansible_Automation/multivendor-validation/pre-post-checks/sros_checks.yml for rtr-nokia-01, rtr-nokia-02

TASK [[Nokia] Gather BGP summary] ***************************************************************
ok: [rtr-nokia-01]
ok: [rtr-nokia-02]

TASK [[Nokia] Gather interface status] ***************************************************************
ok: [rtr-nokia-02]
ok: [rtr-nokia-01]

TASK [[Nokia] Gather routing table] ***************************************************************
ok: [rtr-nokia-02]
ok: [rtr-nokia-01]

TASK [[Nokia] Save pre-check output] ***************************************************************
changed: [rtr-nokia-01 -&gt; localhost]
changed: [rtr-nokia-02 -&gt; localhost]

PLAY RECAP ***************************************************************
rtr-cisco-01               : ok=9    changed=2    unreachable=0    failed=0    skipped=2    rescued=0    ignored=0
rtr-junos-01               : ok=9    changed=2    unreachable=0    failed=0    skipped=2    rescued=0    ignored=0
rtr-nokia-01               : ok=8    changed=2    unreachable=0    failed=0    skipped=2    rescued=0    ignored=0
rtr-nokia-02               : ok=8    changed=2    unreachable=0    failed=0    skipped=2    rescued=0    ignored=0
</code></pre>
<p>File : <code>reports/rtr-cisco-01/20260407_103940/precheck.txt</code></p>
<pre><code class="language-python">=== CISCO IOS-XR PRE-CHECK: rtr-cisco-01 ===
Timestamp: 20260407_103948

--- BGP SUMMARY (IPv4) ---
BGP router identifier 10.10.10.3, local AS number 65000
BGP generic scan interval 60 secs
Non-stop routing is enabled
BGP table state: Active
Table ID: 0xe0000000   RD version: 2
BGP table nexthop route policy:
BGP main routing table version 2
BGP NSR Initial initsync version 2 (Reached)
BGP NSR/ISSU Sync-Group versions 0/0
BGP scan interval 60 secs

BGP is operating in STANDALONE mode.


Process       RcvTblVer   bRIB/RIB   LabelVer  ImportVer  SendTblVer  StandbyVer
Speaker               2          2          2          2           2           0

Neighbor        Spk    AS MsgRcvd MsgSent   TblVer  InQ OutQ  Up/Down  St/PfxRcd
10.10.10.1        0 65000  239205  239192        2    0    0     2w3d          0

--- BGP SUMMARY (IPv6) ---
% None of the requested address families are configured for instance 'default'(36210)

--- INTERFACE STATUS ---
Intf       Intf        LineP              Encap  MTU        BW
               Name       State       State               Type (byte)    (Kbps)
--------------------------------------------------------------------------------
                Lo0          up          up           Loopback  1500          0
              Lo100          up          up           Loopback  1500          0
                Nu0          up          up               Null  1500          0
     Mg0/RP0/CPU0/0          up          up               ARPA  1514    1000000
          Gi0/0/0/0          up          up               ARPA  9212    1000000
          Gi0/0/0/1  admin-down  admin-down               ARPA  1514    1000000
          Gi0/0/0/2          up          up               ARPA  1514    1000000

--- ROUTE SUMMARY ---
Route Source                     Routes     Backup     Deleted     Memory(bytes)
local                            2          0          0           416
connected                        1          1          0           416
application fib_mgr              0          0          0           0
vxlan                            0          0          0           0
static                           0          0          0           0
dagr                             0          0          0           0
bgp 65000                        0          0          0           0
isis 0                           6          1          0           1456
Total                            9          2          0           2288
</code></pre>
<h3>Post-check Playbook</h3>
<p>For this playbook we are still using the same construct as the <strong>pre-check</strong> playbook the only changes done are below</p>
<pre><code class="language-yaml"># change the check_phase to post

- name: "POST-CHANGE CHECKS"
  hosts: core_routers
  gather_facts: false
  vars:
    check_phase: "post"

# change the sros_checks.yml like below 

- name: "[Nokia] Save post-check output"
  copy:
    content: |
      === NOKIA SR OS POST-CHECK: {{ inventory_hostname }} ===
      Timestamp: {{ timestamp }}

      --- BGP SUMMARY (IPv4) ---
      {{ sros_bgp_output.stdout[0] }}

      --- BGP SUMMARY (IPv6) ---
      {{ sros_bgp_output.stdout[1] }}

      --- INTERFACES ---
      {{ sros_intf_output.stdout[0] }}

      --- ROUTE TABLE SUMMARY ---
      {{ sros_route_output.stdout[0] }}
    dest: "{{ output_dir }}/post_check.txt"
  delegate_to: localhost

# Run the playbook 

ansible-playbook -i inventory.yml postcheck.yml
</code></pre>
<h3>Comparison Playbook</h3>
<p>The <code>compare.yml</code> playbook finds the most recent pre and post check files for every device — regardless of which timestamp folder they live in — diffs them, and saves a full validation report.</p>
<pre><code class="language-yaml">- name: "COMPARE PRE/POST CHECKS"
  hosts: localhost
  gather_facts: false

  tasks:
    - name: Find all device directories
      find:
        paths: "{{ playbook_dir }}/reports/"
        file_type: directory
        recurse: no
      register: device_dirs

    - name: Find latest pre-check for each device
      shell: |
        find "{{ item.path }}" -name "pre_check.txt" | sort | tail -1
      loop: "{{ device_dirs.files }}"
      register: latest_pre
      delegate_to: localhost

    - name: Find latest post-check for each device
      shell: |
        find "{{ item.path }}" -name "post_check.txt" | sort | tail -1
      loop: "{{ device_dirs.files }}"
      register: latest_post
      delegate_to: localhost

............. [truncated]
</code></pre>
<div>
<div>💡</div>
<div>Step 1 : <code>Find devices </code>-- Step 2: <code>Find latest pre/post</code> -- Step 3: <code>Diff pre/post</code> -- Step 4 : <code>Build Report</code> -- Step 5 : <code>Save and print</code></div>
</div>

<p>Sample diff output after the change window:</p>
<pre><code class="language-yaml">}
ok: [localhost] =&gt; (item=rtr-cisco-01) =&gt; {
    "msg": [
        "============================================================",
        "Device     : rtr-cisco-01",
        "Pre-check  : /labs/kleburu/python-projects/Ansible_Automation/multivendor-validation/pre-post-checks/reports/rtr-cisco-01/20260407_103940/pre_check.txt",
        "Post-check : /labs/kleburu/python-projects/Ansible_Automation/multivendor-validation/pre-post-checks/reports/rtr-cisco-01/20260407_111244/post_check.txt",
        "============================================================",
        "--- DIFF ---",
        "1,2c1,2",
        "&lt; === CISCO IOS-XR PRE-CHECK: rtr-cisco-01 ===",
        "&lt; Timestamp: 20260407_103948",
        "---",
        "&gt; === CISCO IOS-XR POST-CHECK: rtr-cisco-01 ===",
        "&gt; Timestamp: 20260407_111252",
        "23c23",
        "&lt; 10.10.10.1        0 65000  239205  239192        2    0    0     2w3d          0",
        "---",
        "&gt; 10.10.10.1        0 65000  239272  239258        2    0    0     2w3d          0"
    ]
}
</code></pre>
<h1>Key Takeaways</h1>
<table>
<thead>
<tr>
<th>Benefit</th>
<th>Detail</th>
</tr>
</thead>
<tbody><tr>
<td>Speed</td>
<td>45 min of manual CLI work runs in under minutes across all vendors simultaneously.</td>
</tr>
<tr>
<td>Consistency</td>
<td>Every engineer captures the exact same data points — no more missed checks.</td>
</tr>
<tr>
<td>Auditability</td>
<td>All outputs are timestamped, stored, and diff-able. Perfect for change management.</td>
</tr>
<tr>
<td>Security</td>
<td>Ansible Vault keeps credentials out of your codebase, satisfying compliance requirements.</td>
</tr>
</tbody></table>
<h1>Download Code</h1>
<p>All YAML files are here: <a href="https://gitlab.com/kgosileburu/network-automation-scripts">codednetwork-week-8</a></p>
]]></content:encoded></item><item><title><![CDATA[NETCONF and YANG: Building Programmable Networks ]]></title><description><![CDATA[What is YANG?
YANG ( Yet Another Next Generation) is a data modeling language that defines the structure, semantics, and constraints of configuration and state data for network devices and services. Y]]></description><link>https://codednetwork.com/netconf-and-yang-building-programmable-networks</link><guid isPermaLink="true">https://codednetwork.com/netconf-and-yang-building-programmable-networks</guid><category><![CDATA[netconf]]></category><category><![CDATA[xml]]></category><category><![CDATA[yang]]></category><category><![CDATA[model driven app]]></category><category><![CDATA[schema]]></category><dc:creator><![CDATA[Kgosi Leburu]]></dc:creator><pubDate>Tue, 31 Mar 2026 07:24:46 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/2e3ced16-2bdb-4c5c-ac73-5ec250759a8d.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>What is YANG?</h1>
<p>YANG ( Yet Another Next Generation) is a data modeling language that defines the structure, semantics, and constraints of configuration and state data for network devices and services. YANG modules outline the hierarchical schema, including nodes, types, constraints, RPCs, and notifications. YANG has become the networking industry standard for data modeling because it is human readable, extensible and easy to learn</p>
<p>A YANG model defines a tree structure and data is mapped into this tree.  A model is defined in a text file and comprises a module and, optionally, submodules, which when compiled together form the tree.</p>
<p>YANG is a way to enforce constraints on data inputs. These may inputs used from an API encoded as XML and JSON. The device will check if the data adheres to the underlying model</p>
<h2>YANG Module Definition</h2>
<p><strong>module</strong> <code>nokia-types-bgp</code></p>
<pre><code class="language-json">module nokia-types-bgp {


yang-version "1.1";

namespace "urn:nokia.com:sros:ns:yang:sr:types-bgp";

prefix "types-bgp";

import nokia-sros-yang-extensions     { prefix "sros-ext"; }
import nokia-types-sros               { prefix "types-sros"; }

sros-ext:sros-major-release "rel22";

revision "2022-05-03";

typedef llgr-family-identifiers {
    type enumeration {
        enum "ipv4"                         { value 1; }
        enum "vpn-ipv4"                     { value 2; }
        enum "ipv6"                         { value 3; }
        enum "vpn-ipv6"                     { value 5; }
        enum "l2-vpn"                       { value 6; }
        enum "flow-ipv4"                    { value 10; }
        enum "route-target"                 { value 11; }
        enum "flow-ipv6"                    { value 14; }
        enum "label-ipv4"                   { value 17; }
        enum "label-ipv6"                   { value 18; }
        enum "flow-vpn-ipv4"                { value 23; }
        enum "flow-vpn-ipv6"                { value 24; }
    }

}
</code></pre>
<p>A <code>module name</code> is specified in the module <code>nokia-types-bgp</code> section, with <code>nokia-types-bgp</code> as the name of the new YANG module. A <code>yang-version</code> indicates the version number of the YANG definition used by the author, not the module itself. This is typically 1.0 or 1.1. For new modules, it's recommended to start with the latest version.</p>
<p>A <code>prefix</code> is a short name used within YANG modules for quick reference to the modules. An <code>import</code> is used alongside the module name of the YANG module being imported.</p>
<p>A <code>revision number</code> is formatted as a date and should be updated whenever the module changes. A <code>type definition (typedef)</code> defines custom types using standardized YANG elements.</p>
<h2>Exploring YANG in Depth</h2>
<h3>YANG STATEMENTS</h3>
<p>Leaf Nodes</p>
<ul>
<li><p>Simple data such as an integer or a string</p>
</li>
<li><p>Represents a single value</p>
</li>
<li><p>No children</p>
</li>
</ul>
<pre><code class="language-python"># YANG

leaf host-name { 
     type string;
     description "Hostname for this system"; 
}

# NETCONF XML 

&lt;host-name&gt;codednetwork.com&lt;/host-name&gt;
</code></pre>
<p>Leaf-List Nodes</p>
<ul>
<li><p>This is just like leaf statement but there can be multiple instances</p>
</li>
<li><p>one value of a particular type per leaf</p>
</li>
</ul>
<pre><code class="language-python"># YANG

leaf-list name-server { 
type string; 
description "List of DNS servers to query"
}

# NETCONF XML
 
&lt;name-server&gt;8.8.8.8&lt;/name-server&gt;
&lt;name-server&gt;4.4.4.4&lt;/name-server&gt;
</code></pre>
<p>List Nodes</p>
<ul>
<li><p>It allows you to create a list of leafs or leaf-lists</p>
</li>
<li><p>Each entry is structure or a record instance</p>
</li>
</ul>
<pre><code class="language-python"># YANG

list vlan { 
   key "id";
     leaf id { 
       type int; 
       range 1..4094; 
     } 
     leaf name { 
        type string;
     } 
}

# NETCONF XML 

&lt;vlan&gt; 
  &lt;id&gt;100&lt;/id&gt;
  &lt;name&gt;web_vlan&lt;/name&gt;
&lt;/vlan&gt;
&lt;vlan&gt; 
  &lt;id&gt;200&lt;/id&gt;
  &lt;name&gt;app_vlan&lt;/name&gt;
&lt;/vlan&gt;
</code></pre>
<p>Container Nodes</p>
<ul>
<li><p>It is used to group nodes in a subtree</p>
</li>
<li><p>It only contains child nodes and has no value</p>
</li>
<li><p>May contain number of child nodes of any type (including leafs, lists, containers and leaf-lists</p>
</li>
</ul>
<pre><code class="language-python"># YANG

container system {
    container login-control {
       container pre-login message {
              leaf message {
                   type (*); 
                   description
                   "Message displayed prior to the login prompt";
       }
    }
}

# NETCONF XML 

&lt;system&gt; 
      &lt;login-control&gt;
           &lt;pre-login message&gt;
                      &lt;message&gt; Learning Yang is cool &lt;/message&gt;
           &lt;/pre-login message&gt;
      &lt;/login-control&gt;
&lt;/system&gt;
</code></pre>
<h1>What is NETCONF ?</h1>
<h2>Overview</h2>
<p>NETCONF is a standardized IETF configuration management protocol specified in RFC 6241, known as <em>Network Configuration Protocol (NETCONF)</em> . It is secure, connection-oriented, and runs on top of the SSHv2 transport protocol as specified in RFC 6242 . NETCONF is an XML-based protocol that can be used as an alternative to CLI or SNMP for managing an SR OS router</p>
<p>NETCONF uses RPC messaging for communication between NETCONF client and NETCONF server running on SROS. An RPC message and configuration or state data is encapsulated within an XML document. The SR OS NETCONF interface supports configuration, state and various router operations</p>
<pre><code class="language-python">Client                                      Server  
|=== TRANSPORT ================================| 
|&lt;--- TCP SYN --------------------------------&gt;|
|&lt;-- TCP SYN-ACK -----------------------------&gt;|
|--- SSH handshake + userauth ----------------&gt;| 
|&lt;-- SSH userauth success ---------------------|
|--- SSH subsystem "netconf" request ---------&gt;| 
|&lt;-- SSH channel success ----------------------|
|                                              | 
|=== SESSION ==================================|
|&lt;--&lt;hello&gt; session-id=42, capabilities -------|
|---&lt;hello&gt; capabilities ---------------------&gt;| 
|                                              |
|=== OPERATIONS ===============================|
|--- &lt;rpc&gt; get-config(running) ---------------&gt;|
|&lt;--&lt;rpc-reply&gt; &lt;data&gt;…&lt;data&gt; -----------------|
|                                              |
|=== TEARDOWN =================================| 
|--- &lt;rpc&gt; close-session ---------------------&gt;| 
|&lt;--&lt;rpc-reply&gt; &lt;ok/&gt; -------------------------| 
|--- SSH channel close -----------------------&gt;|
</code></pre>
<p><em>message exchange</em></p>
<h2>Protocol Stack</h2>
<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/c3ad2ba7-9aad-4565-b0ba-ceec9b781169.png" alt="" style="display:block;margin:0 auto" />

<table>
<thead>
<tr>
<th><strong>Content</strong></th>
<th><strong>yang formatted information</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>Operations</strong></td>
<td><strong>Specific functions that operators can do on the server (get-config, edit-config)</strong></td>
</tr>
<tr>
<td><strong>Messages</strong></td>
<td><strong>three main message types - <em>Remote Procedure Calls (RPCs)</em> : Instructions /requests formatted in XML . <em>Notification</em> : information provided from the server not in direct response to a RPC. <em>Hello</em> : Special message used in communication setup and capabilities discovery</strong></td>
</tr>
<tr>
<td><strong>Secure Transport</strong></td>
<td><strong>NETCONF is not a transport layer. It is layered on top of a secured-orientated transport protocol eg SSH , HTTP/TLS</strong></td>
</tr>
</tbody></table>
<h2>Enabling NETCONF Nokia SROS</h2>
<pre><code class="language-shell"># Step 1 - Ensure model-driven mode is enabled 

A:R1# configure system management-interface configuration-mode model-driven

# Step 2 - Enable NETCONF and Auto-save 

(gl)[configure system management-interface]
A:admin@R1# 
    netconf { 
    admin-state enable
    auto-config-save true 
    }

# Step 3 - Select the YANG Modules 

(gl)[configure system management-interface]
A:admin@R1#
    yang-modules { 
       nokia-modules false 
       nokia-combined-modules true
    }

# Step 4 - Create user with NETCONF access 

(gl)[configure local-user { 
        user "netconf" { 
            password "&lt;password-here&gt;" 
            access { 
                netconf true 
            } 
            console { 
               member ["administrative"] 
            } 
        } 
} system security user-params]

# Step 5 - Grant lock and kill permissions 

(gl)[configure system security aaa local-profiles profile "administrative"] A:admin@R1#
    netconf { 
        base-op-authorization {
            kill-session true 
            lock true
        }
    }
</code></pre>
<h2>NETCONF - Base Operations</h2>
<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/7c40db98-434b-481d-a70d-1370cf411d61.png" alt="" style="display:block;margin:0 auto" />

<h2>NETCONF - Interacting with Nokia SROS</h2>
<h3>Connect with SSH</h3>
<p>The simplest and the easiest way to interact with a router is by using SSH.</p>
<pre><code class="language-python">ssh admin@&lt;host-ip&gt; -p 830 -s netconf
</code></pre>
<div>
<div>💡</div>
<div><strong><em>&lt;hello&gt; </em></strong><em>returned with router capabilities</em></div>
</div>

<pre><code class="language-xml">&lt;?xml version="1.0" encoding="UTF-8"?&gt;
&lt;hello xmlns="urn:ietf:params:xml:ns:netconf:base:1.0"&gt;
    &lt;capabilities&gt;
       &lt;capability&gt;urn:ietf:params:netconf:base:1.0&lt;/capability&gt;
       &lt;capability&gt;urn:ietf:params:netconf:base:1.1&lt;/capability&gt;
       &lt;capability&gt;urn:ietf:params:netconf:capability:candidate:1.0
       &lt;/capability&gt;
       &lt;capability&gt;urn:ietf:params:netconf:capability:confirmed-commit:1.1
       &lt;/capability&gt;
       &lt;capability&gt;urn:ietf:params:netconf:capability:rollback-on-error:1.0
       &lt;/capability&gt;
       &lt;capability&gt;urn:ietf:params:netconf:capability:notification:1.0
       &lt;/capability&gt;
       &lt;capability&gt;urn:ietf:params:netconf:capability:interleave:1.0
       &lt;/capability&gt;
       &lt;capability&gt;urn:ietf:params:netconf:capability:validate:1.0
       &lt;/capability&gt;
       &lt;capability&gt;urn:ietf:params:netconf:capability:validate:1.1
       &lt;/capability&gt;
       &lt;capability&gt;urn:ietf:params:netconf:capability:startup:1.0
       &lt;/capability&gt;
       &lt;capability&gt;urn:ietf:params:netconf:capability:url:1.0
       scheme=ftp,tftp,file&lt;&lt;/capability&gt;
       &lt;capability&gt;urn:ietf:params:xml:ns:yang:ietf-netconf-monitoring
       &lt;/capability&gt;
       &lt;capability&gt;urn:nokia.com:sros:ns:yang:sr:major-release:24
       &lt;/capability&gt;
       &lt;capability&gt;urn:ietf:params:xml:ns:yang:iana-if-type?module=iana-if-   
       type&amp;revision=2014-05-08&lt;/capability&gt;
       &lt;capability&gt;urn:ietf:params:netconf:capability:yang-library:1.0?    
        revision=2016-06-21&amp;module-set-id=gOPkB60aiVyk5&lt;
       &lt;/capability&gt;
    &lt;/capabilities&gt;
     &lt;/session-id&gt;57677&lt;session-id&gt;
&lt;/hello&gt;
</code></pre>
<p><em>truncated capabilities</em></p>
<div>
<div>💡</div>
<div>Send a hello (mandatory first step) finish the message with ]]&gt;]]&gt;</div>
</div>

<pre><code class="language-xml">&lt;?xml version="1.0" encoding="UTF-8"?&gt;
&lt;hello xmlns="urn:ietf:params:xml:ns:netconf:base:1.0"&gt;
&lt;capabalities&gt;
&lt;capability&gt;urn:ietf:params:netconf:base:1.0&lt;/capability&gt;
&lt;/capabilities&gt;
&lt;/hello&gt;
]]&gt;]]&gt;
</code></pre>
<div>
<div>💡</div>
<div>&lt;get-config&gt; retrieving the services from the configuration datastore</div>
</div>

<pre><code class="language-xml"># Retrieve the services
 
&lt;?xml version="1.0" encoding="UTF-8"?&gt;
&lt;rpc message-id="101" xmlns="urn:ietf:params:xml:ns:netconf:base:1.0"&gt;
  &lt;get-config&gt;
    &lt;source&gt;&lt;candidate/&gt;&lt;/source&gt; 
    &lt;filter&gt;
      &lt;configure xmlns="urn:nokia.com:sros:ns:yang:sr:conf"&gt;
        &lt;service&gt;
        &lt;/service&gt;
      &lt;/configure&gt;
    &lt;/filter&gt;
  &lt;/get-config&gt;
&lt;/rpc&gt;
]]&gt;]]&gt;

# RPC-REPLY with services 

&lt;?xml version="1.0" encoding="UTF-8"?&gt;
&lt;rpc-reply message-id="101" xmlns="urn:ietf:params:xml:ns:netconf:base:1.0&gt;
   &lt;data&gt;
       &lt;configure xmlns="urn:nokia.com:sros:ns:yang:sr:conf" xmlns:nokia-attr="urn:nokia.com:sros:ns:yang:sr:attributes"&gt;
           &lt;service&gt;
               &lt;epipe&gt;
                   &lt;service-name&gt;test&lt;/service-name&gt;
                   &lt;admin-state&gt;enable&lt;/admin-state&gt;
                   &lt;service-id&gt;10&lt;/service-id&gt;
                   &lt;customer&gt;1&lt;/customer&gt;
                   &lt;spoke-sdp&gt;
                      &lt;sdp-bind-id&gt;10:10&lt;/sdp-bind-id&gt;
                      &lt;admin-state&gt;enable&lt;/admin-state&gt;
                   &lt;/spoke-sdp&gt;
                &lt;/epipe&gt;
                &lt;sdp&gt;
                  &lt;sdp-id&gt;10&lt;/sdp-id&gt;
                  &lt;admin-state&gt;enable&lt;/admin-state&gt;
                  &lt;description&gt;pysros-example&lt;/description&gt;
                  &lt;delivery-type&gt;mpls&lt;/delivery-type&gt;
                  &lt;far-end&gt; 
                      &lt;ip-address&gt;10.10.10.2&lt;/ip-address&gt;
                  &lt;/far-end&gt;
                  &lt;lsp&gt;
                    &lt;lsp-name&gt;PE1-to-PE2&lt;/lsp-name&gt;
                  &lt;/lsp&gt;
                &lt;/sdp&gt;
         &lt;/service&gt;
       &lt;/configure&gt;
   &lt;/data&gt;
&lt;/rpc-reply&gt;
</code></pre>
<h3>Connect with netconf-console2</h3>
<p><code>netconf-console2</code> is a console application built to interact with network devices using NETCONF. It allows engineers and automation systems to send NETCONF Remote Procedure Call (RPC) operations to a device. It can run in two modes:</p>
<ul>
<li><p><strong>Command-line mode:</strong> Used to execute one or more RPC operations in a single shell command.</p>
</li>
<li><p><strong>Interactive (console) mode:</strong> Provides an interactive session where a user can issue multiple commands sequentially, with limited support for tab-completion</p>
</li>
</ul>
<div>
<div>💡</div>
<div>In this below example we just show retrieving configuration using &lt;get-config&gt;</div>
</div>

<pre><code class="language-python"># Install netconf-console2 
pip install netconf-console2 

# Retrieve configuration 

netconf-console2 --host=&lt;host-ip&gt; -u &lt;username&gt; -p &lt;password&gt; --port=830 --get-config

# Configuration results 

&lt;?xml version='1.0' encoding='UTF-8'?&gt; 
&lt;data xmlns="urn:ietf:params:xml:ns:netconf:base:1.0"
xmlns:nc="urn:ietf:params:xml:ns:netconf:base:1.0"&gt;
        &lt;configure xmlns="urn:nokia.com:sros:ns:yang:sr:conf" xmlns:nokia-attr="urn:nokia.com:sros:ns:yang:sr:attributes"&gt;
            &lt;groups&gt;
                &lt;group&gt; 
                    &lt;name&gt;test&lt;/name&gt;
                    &lt;router&gt;
                       &lt;router-name&gt; Base &lt;/router-name&gt;
                       &lt;interface&gt;loop&lt;/interface&gt;
                       &lt;ip-mtu&gt;444&lt;/ip-mtu&gt;
                       &lt;/interface&gt;
                    &lt;/router&gt;
                &lt;group&gt;              
            &lt;/groups&gt;
            &lt;card&gt;
                &lt;slot-number&gt;1&lt;/slot-number&gt;
                &lt;card-type&gt;iom-1&lt;card-type&gt;
                &lt;mda&gt;
                   &lt;mda-slot&gt;1&lt;/mda-slot&gt;
                   &lt;mda-type&gt;me12-100gb-qsfp28&lt;/mda-type&gt;
                &lt;/mda&gt;
            &lt;/card&gt;

............. [output truncated]
</code></pre>
<h3>Connect with NETCONF client for Visual Studio Code</h3>
<p>This extension adds an interactive NETCONF client to Visual Studio Code, connecting to NETCONF servers such as NOKIA IP routers (SR OS and SRLinux). It brings NETCONF into the editor so you can manage modern network equipment directly from VS Code using the standard NETCONF protocol.</p>
<p>If you're not familiar with Python or using libraries like NAPALM and ncclient to interact with a router running NETCONF, this VS Code extension is perfect for you. The interface is user-friendly and easy to navigate.</p>
<div>
<div>💡</div>
<div>Download the extension on VS Code Marketplace. Add the router with the correct NETCONF user and password before interaction</div>
</div>

<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/7bf98c06-2182-4d8c-99f9-778a5e7af3bd.jpg" alt="" style="display:block;margin:0 auto" />

<p><em>Create the connect-name , host-ip and enter the credentials</em></p>
<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/9c49d629-4121-4398-8837-10e00a6469fc.jpg" alt="" style="display:block;margin:0 auto" />

<p><em>Click connect icon to start interacting with the Nokia SROS</em></p>
<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/1e94783d-280f-479b-bef2-2287a9c9edb7.jpg" alt="" style="display:block;margin:0 auto" />

<p><em>Base operations options</em></p>
<h1>Other Tools</h1>
<h2>PYANG</h2>
<p>pyang is a YANG validator and converter that checks YANG modules for correctness and generates documentation or code stubs.</p>
<p><code>Convert YANG into a tree diagram</code></p>
<pre><code class="language-python"># Execute the below command 

pyang -f tree nokia-state.yang &gt; nokia-state.tree
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/7e8f7529-54f9-47c0-b812-761fd5ec0e04.png" alt="" style="display:block;margin:0 auto" />

<p><em>nokia-state tree structure sample</em></p>
<p><code>Generate html/javascript tree</code></p>
<pre><code class="language-python"># Execute the below command 

pyang -f jstree nokia-state.yang &gt; nokia-state.jstree
</code></pre>
<p>Then you can browse the YANG on a web browser</p>
<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/fee9ed1a-3f8a-4aaf-b58d-c236de07f50d.jpg" alt="" style="display:block;margin:0 auto" />

<h1>Conclusion</h1>
<p>The CLI era gave network engineers power and flexibility, but at the cost of machine-readability, validation, and safe rollback. Every vendor implemented their own syntax, every script was fragile, and every change carried the risk of an unrecoverable typo. NETCONF and YANG fundamentally transform the way network configurations are managed , the device exposes a schema, accepts structured configuration, validates every change before it is deployed, and can roll back automatically.</p>
<p>That shift — from text scraping to model-driven management — is not just a technical improvement. It is what makes large-scale network automation reliable enough to trust in production. When your automation toolchain uses YANG, it knows what a valid interface configuration looks like before it ever touches a router. When it uses <em>confirmed commit</em>, a misconfiguration cannot disable a device permanently. When it subscribes to NETCONF notifications, it reacts to events rather than polling every 60 seconds hoping nothing changed.</p>
<blockquote>
<p><em>" The combination of a strongly-typed schema language, a transactional RPC protocol, and a mandatory encrypted transport is not accidental — it is a deliberate architecture designed for the scale and reliability demands of modern network operations "</em></p>
</blockquote>
]]></content:encoded></item><item><title><![CDATA[Automate Nokia SR OS with pySROS: A Practical Deep Dive
]]></title><description><![CDATA[What is pySROS and why does it matter?
Managing Nokia SR OS routers, such as the 7750 SR, 7450 ESS, 7210 SAS and the rest of the family, has typically involved dealing with CLI automation through Netm]]></description><link>https://codednetwork.com/automate-nokia-sr-os-with-pysros-a-practical-deep-dive</link><guid isPermaLink="true">https://codednetwork.com/automate-nokia-sr-os-with-pysros-a-practical-deep-dive</guid><category><![CDATA[Python]]></category><category><![CDATA[nokia]]></category><category><![CDATA[yang]]></category><category><![CDATA[model driven app]]></category><category><![CDATA[command line]]></category><dc:creator><![CDATA[Kgosi Leburu]]></dc:creator><pubDate>Tue, 24 Mar 2026 07:15:32 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/01a22a62-e276-4463-82cd-dc4bdbb02e48.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>What is pySROS and why does it matter?</h1>
<p>Managing Nokia SR OS routers, such as the 7750 SR, 7450 ESS, 7210 SAS and the rest of the family, has typically involved dealing with CLI automation through Netmiko or creating a custom NETCONF client. While both methods are functional, they have drawbacks: screen-scraping CLI output can fail with software updates, and manually crafting NETCONF XML payloads is cumbersome and prone to errors.</p>
<p><strong>pySROS</strong> is Nokia's answer: pySROS libraries provide a model-driven management interface for Python developers to integrate with supported Nokia routers running the Service Router Operating System (SR OS). The libraries provide an <strong>Application Programming Interface</strong> (API) for developers to create applications that can interact with Nokia SR OS devices, whether those applications are executed from a development machine, a remote server, or directly on the router</p>
<div>
<div>💡</div>
<div><strong>Key insight</strong>: pySROS is YANG schema aware. Each element has knowledge of its path, model, and data type in the YANG model. The advertised capability provides information about the schemas supported by SR OS which allows a NETCONF client to query and retrieve schema information from the SR OS NETCONF server.</div>
</div>

<table>
<thead>
<tr>
<th>FEATURE</th>
<th>DESCRIPTION</th>
</tr>
</thead>
<tbody><tr>
<td>Model-driven</td>
<td>Full YANG schema awareness — Nokia native and OpenConfig modules</td>
</tr>
<tr>
<td>Write once, run anywhere</td>
<td>Same script on your laptop or directly on the SR OS node</td>
</tr>
<tr>
<td>NETCONF transport</td>
<td>Remote execution uses NETCONF/SSH; full config and state access</td>
</tr>
<tr>
<td>On-box MicroPython</td>
<td>SR OS ships a MicroPython interpreter for on-device automation</td>
</tr>
<tr>
<td>OpenConfig support</td>
<td>Works with OpenConfig YANG for multi-vendor normalisation</td>
</tr>
</tbody></table>
<h1>YANG Modeling</h1>
<p>YANG is a language that is designed to be readable by both humans and machines in order to model configuration and state information. YANG is rapidly becoming the standard way to model network devices and network device information. YANG is defined in the following RFCs:</p>
<ul>
<li><p><em>RFC 6020 - YANG - A Data Modelling Language for the Network Configuration Protocol (NETCONF)</em></p>
</li>
<li><p><em>RFC 6021 - Common YANG Data Types</em></p>
</li>
<li><p><em>RFC 7950 - The YANG 1.1 Data Modelling Language</em></p>
</li>
</ul>
<h2>Model paths</h2>
<p>At the core of the pySROS libraries are Nokia's model-driven management concepts built into SR OS. Communication between applications developed with pySROS libraries and Nokia SR OS routers is facilitated through model-driven paths that reference elements within the Service Router Operating System. The pySROS libraries use modeled paths in the JSON instance path format, which describes the referenced YANG models, including all YANG lists, list keys, and their values (though these may sometimes be omitted).</p>
<p>To obtain the JSON instance path directly from an SR OS router running software from release 21.7.R1, enter <code>pwc json-instance-path</code> in the MD-CLI within the relevant context.</p>
<p><em>SR OS</em> <code>pwc json-instance-path</code> <em>output from service configuration</em></p>
<pre><code class="language-plaintext">(gl)[/configure service vprn "10" bgp-ipvpn mpls] 
A:admin@R1# pwc json-instance-path 
Present Working Context: /nokia-conf:configure/service/vprn[service-name="10"]/bgp-ipvpn/mpls
</code></pre>
<p><em>SR OS</em> <code>pwc json-instance-path</code> <em>output from BGP State</em></p>
<pre><code class="language-plaintext">(gl)[/state router "Base" bgp neighbor "10.10.10.4"] 
A:admin@R1# pwc json-instance-path
Present Working Context: /nokia-state:state/router[router-name="Base"]/bgp/neighbor[ip-address="10.10.10.4"]
</code></pre>
<div>
<div>💡</div>
<div>Data is obtained or configured via the pySROS libraries using these path formats</div>
</div>

<h1>Prerequisites and Installation</h1>
<p>To use the pySROS libraries, the following pre-requisites must be met:</p>
<ul>
<li><p>one or more SR OS nodes running in model-driven management interface configuration mode</p>
</li>
<li><p>Running SR OS 21.7.R1 or greater (to execute applications on the SR OS device)</p>
</li>
<li><p>With NETCONF enabled and accessible by an authorized user (to execute applications remotely)</p>
</li>
<li><p>Python 3 interpreter of version 3.6 or newer when using the pySROS libraries to execute applications remotely</p>
</li>
</ul>
<pre><code class="language-python">#Create and activate a virtual environment
python3 -m venv env
source env/bin/activate

#Install pySROS from PyPI
pip install pysros
pip install --upgrade pysros

#Or clone from GitHub
git clone https://github.com/nokia/pysros 
python3 setup.py install
</code></pre>
<h1>Architecture</h1>
<h2>On-box vs Off-box execution</h2>
<p>pySROS supports two distinct execution modes. Understanding the trade-offs is the first design decision for any automation project.</p>
<pre><code class="language-python">Off-box: [Python script] ──── NETCONF/SSH ────▶ [SR OS node]
        dev machine / automation server          7750 SR / 7250 IXR

On-box: [SR OS node] ▶ [pyexec /cron /EHS/alias] ▶ [pySROS script]
                                               MicroPython Interpreter
</code></pre>
<p><strong>Off-box</strong> execution gives you several advantages to name a few: the ability to interact with multiple routers from a single script, executing scripts with a large data set, no impact on CPU / memory, Easier integration with other systems and the most important one is Access to richer Python ecosystem (Prometheus, Grafana, NetBox, Streamlit, etc.)</p>
<p><strong>On-box</strong> execution runs inside the router itself via MicroPython — a lean Python 3 implementation designed for constrained environments. Execution time and memory usage are bounded by SR OS to protect routing stability. You can only address the local device and and the script can be triggered via CLI command aliases, EHS event handlers, cron jobs, or the <code>pyexec</code> command.</p>
<div>
<div>💡</div>
<div>Python applications are configured in the <code>configure python</code> context. The<code> pyexec</code> command takes a parameter, the <em>name</em> of a Python application from the SR OS configuration <code>configure&gt;python&gt;python-script&gt;application_name</code> or the URL to the location (local/remote) of a Python application</div>
</div>

<h1>Getting Started</h1>
<h2>Lab Setup</h2>
<p>We continue using the lab setup we built for our multivendor environment, and this is made possible by <a href="https://containerlab.dev/">Containerlab</a></p>
<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/03dc5158-a6ad-4972-95e7-771310793a3e.png" alt="" style="display:block;margin:0 auto" />

<div>
<div>💡</div>
<div>TopoViewer - Interactive topology visualization and editing for <em>Containerlab </em>network labs directly in VS Code.</div>
</div>

<p>Our lab consists of the below components:</p>
<p>Servers:</p>
<ul>
<li><p>Rocky Linux 9.6</p>
</li>
<li><p>Python 3.9</p>
</li>
<li><p>pySROS 23.3.3</p>
</li>
</ul>
<p>Network Devices:</p>
<ul>
<li>Nokia SR OS 24.10.R5</li>
</ul>
<h2>Making a connection</h2>
<p>To access the model-driven interface of SR OS while running a Python application from a remote workstation, use the <code>pysros.management.connect()</code> method. When executing a Python application on SR OS, the same method is used, but its arguments are ignored</p>
<p><strong>File</strong> : <code>connect.py</code></p>
<pre><code class="language-python">from pysros.management import connect
from pysros.exceptions import *
import sys


def get_connection(): 
    try: connection_object = connect(host="&lt;host-ip&gt;", 
                                     username="username", 
                                     password="password")

    except RuntimeError as error1:
        print("Failed to connect.  Error:", error1)
        sys.exit(-1)
    except ModelProcessingError as error2:
        print("Failed to create model-driven schema.  Error:", error2)
        sys.exit(-2)

if __name__ == "__main__":
    connection_object = get_connection()
    print("\n Connection established successfully \n")
</code></pre>
<p><strong>Expected Output</strong></p>
<pre><code class="language-python">Connection established successfully
</code></pre>
<h2>Compliance Tool</h2>
<p>The compliance script (<code>compliance.py</code>) connects to an SR OS router to validate its configuration against a predefined golden template, which consists of YANG model paths and their expected values.</p>
<p>For each check, it calls <code>c.running.get(path)</code> and compares the returned <code>.data</code> value against the expected string. If the path doesn't exist or the value doesn't match, it's recorded as a violation. At the end it prints a report showing passed vs failed checks, with the full YANG path, expected value, and actual value for each violation.</p>
<p><strong>File</strong> : <code>compliance.py</code></p>
<pre><code class="language-python">from pysros.management import connect
import sys
import datetime

GOLDEN_CHECKS = [
    # Security baseline
    (
        "/nokia-conf:configure/system/login-control/pre-login   message/message",
        "Authorized access only. All activity is monitored."
    ),
    (
        "/nokia-conf:configure/system/security/telnet-server",
        "False"
    ),
    (
        "/nokia-conf:configure/system/security/ftp-server",
        "True"
    ),

    # NTP must be enabled — correct path is under system/time/ntp
    (
        "/nokia-conf:configure/system/time/ntp/admin-state",
        "enable"
    ),
    # Router-id must exist (we just check presence, not value)
    (
        "/nokia-conf:configure/router[router-name='Base']/router-id",
        None
    ),
    # SNMP community must exist
    (
        "/nokia-conf:configure/system/security/snmp",
        None
    ),
    #  ISIS should be enabled
    (
        "/nokia-conf:configure/router[router-name='Base']/isis[isis-instance='0']/admin-state",
        "enable"
    ),
]

def check_compliance(c, checks):

    violations = []
    passed = 0

    for path, expected in checks:
        try:
            val = c.running.get(path)
            actual = val.data if hasattr(val, "data") else str(val)
            
            if expected is not None and str(actual) != str(expected):
                violations.append((path, expected, actual))
            else:
                passed += 1
       
        except Exception:
            violations.append((path, expected, "MISSING"))
    
        return violations, passed

def print_report(violations, passed, total):
    
    ts = datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC")
    print()
    print("=" * 70)
    print(f"  Nokia SR OS Config Compliance Report")
    print(f"  Generated: {ts}")
    print("=" * 70)
    print(f"  Checks:  {total}  |  Passed: {passed}  |  Violations: {len(violations)}")
    print("=" * 70)
    
    if not violations:
        print()
        print("  [COMPLIANT]  All checks passed.")
        print()
        return
     
    print()
    print("  VIOLATIONS FOUND:")
    print()
    for i, (path, expected, actual) in enumerate(violations, 1):
        # Shorten very long paths for readability
        short_path = path.split("/")[-1] if "/" in path else path
        print(f"  [{i}] {short_path}")
        print(f"      Full path : {path}")
        print(f"      Expected  : {expected if expected is not None else '(must exist)'}")
        print(f"      Actual    : {actual}")
        print()

if __name__ == "__main__":
    c = connect(host="&lt;host-name&gt;" , username="username" , password="password")

    total = len(GOLDEN_CHECKS)
    violations, passed = check_compliance(c, GOLDEN_CHECKS)

    print_report(violations, passed, total)

    c.disconnect()
</code></pre>
<p><strong>Expected Output</strong></p>
<pre><code class="language-python">=============================================================
 Nokia SR OS Config Compliance 
 Report Generated: 2026-03-23 02:22:26 UTC
=============================================================
 Checks: 7 | Passed: 6 | Violations: 1
=============================================================

 VIOLATIONS FOUND:
 [1] admin-state
     Full path : /nokia-conf:configure/system/time/ntp/admin-state
     Expected  : enable
     Actual    : disable
</code></pre>
<div>
<div>💡</div>
<div>In this scenario ntp was <code>disabled </code>on the router and a <code>violation </code>was found</div>
</div>

<pre><code class="language-python"># Enabling NTP 

[gl:/configure system time ntp]
A:admin@PE1# info json
{ 
 "nokia-conf:admin-state": "enable"
}

# NTP was enabled and all checks passed with no violations 

============================================================
   Nokia SR OS Config Compliance 
   Report Generated: 2026-03-23 09:08:47 UTC
=============================================================
   Checks: 7 | Passed: 7 | Violations: 0
=============================================================
</code></pre>
<h2>CLI Command Alias</h2>
<p>A command alias in SR OS lets you expose a pySROS script as a native MD-CLI command. From the operator's perspective they just type a short command — the Python execution is invisible.</p>
<p><strong>How it works</strong></p>
<p>The script lives on the router's compact flash. SR OS's MD-CLI alias config points at it, and when the operator runs the alias, SR OS invokes the Python script to execute. It's the cleanest way to give network operators custom show commands without requiring them to know Python or pySROS exists.</p>
<p><strong>File</strong> : <code>interfaceproto.py</code></p>
<p><strong>Configuration</strong></p>
<pre><code class="language-python"># Step 1 - Configure the python-script 

[gl:/configure python python-script "interfaceproto"]
A:admin@PE1# info json
{
    "nokia-conf:admin-state": "enable",
    "nokia-conf:urls": ["cf3:\interfaceproto.py"],
    "nokia-conf:version": "python3"
}

# Step 2 - check the status of the Python Script 
/show python python-script "interfaceproto"
============================================================
Python script "interfaceproto"
============================================================
Description : (Not Specified)
Admin state : inService 
Oper state : inService
Oper state 
(distributed) : inService
Version : python3 
Action on fail: drop 
Protection : none
Primary URL : cf3:\interfaceproto.py
Secondary URL : (Not Specified) 
Tertiary URL : (Not Specified) 
Active URL : primary
Run as user : (Not Specified) 
Code size : 863 
Last changed : 03/23/2026 09:23:04

# Step 3 - Configure the command alias and mount it 

[gl:/configure system management-interface cli md-cli environment command-alias alias "intprotocol"]
info 
admin-state enable 
python-script "interfaceproto" 
mount-point "/show" { }

# Step 4 - Sanity check the alias configured 
A:admin@PE1# /show ?
Aliases: intprotocol - Command-alias

# Step 5 - Run the command protocols will be displayed for each interface 
A:admin@PE1# /show intprotocol
=============================================================
 Router Base Interfaces
=============================================================
 Interface          Oper State IPv4 State IPv4 Address 
 Protocols
-------------------------------------------------------------
 system             up          up        10.10.10.1
 isis mpls rsvp 
 tocisco_xrv9000    up          up        192.168.1.1 
 isis mpls rsvp ldp 
 toPE2              up          up        192.169.1.1 
 isis mpls rsvp ldp 
 loop               dormant     down      N/A
=============================================================
</code></pre>
<h1>Practical use cases</h1>
<ol>
<li><p><strong>Bulk interface auditing</strong> — Iterate over all interfaces across a fleet, collect IP/admin/oper-state into a structured report.</p>
</li>
<li><p><strong>EHS event-driven remediation</strong> — Trigger a pySROS script via EHS when a BGP session drops. Log context, attempt recovery, or notify an external system.</p>
</li>
<li><p><strong>Custom MD-CLI aliases</strong> — Wrap a pySROS script as an MD-CLI alias. Operators run a natural command without knowing Python is underneath.</p>
</li>
<li><p><strong>Scheduled config compliance</strong> — Run a pySROS script via SR OS cron hourly to validate config against a golden template and log deviations.</p>
</li>
<li><p><strong>Multi-vendor OpenConfig normalisation</strong> — Use OpenConfig YANG modules on SR OS alongside other vendors for a unified config and state model.</p>
</li>
</ol>
<h1>Conclusion</h1>
<p>pySROS represents a significant change in approaching Nokia SR OS automation. Traditionally, there has been operational friction between tasks performed from a management server and those executed on the router, involving different tools, credentials, failure modes, and duplicated logic. pySROS eliminates this divide by treating the execution context as a runtime detail rather than an architectural limitation.</p>
<table>
<thead>
<tr>
<th>Resource</th>
<th>URL</th>
</tr>
</thead>
<tbody><tr>
<td>pySROS official docs</td>
<td><a href="https://network.developer.nokia.com/static/sr/learn/pysros/latest/index.html">Learn PySROS</a></td>
</tr>
<tr>
<td>pySROS GitHub (examples)</td>
<td><a href="https://github.com/nokia/pysros">pySROS Git</a></td>
</tr>
<tr>
<td>SR OS YANG models</td>
<td><a href="http://github.com/nokia/7x50_YangModels">7X50 Yang Model</a></td>
</tr>
</tbody></table>
]]></content:encoded></item><item><title><![CDATA[Turn Scripts into Services: Why Network Automation needs a Web User Interface ?]]></title><description><![CDATA[Network automation has transformed how modern infrastructure teams operate. Scripts that once took hours of manual work — configuring devices, pulling inventory, validating compliance — now execute in]]></description><link>https://codednetwork.com/turn-scripts-into-services-why-network-automation-needs-a-web-user-interface</link><guid isPermaLink="true">https://codednetwork.com/turn-scripts-into-services-why-network-automation-needs-a-web-user-interface</guid><category><![CDATA[Python]]></category><category><![CDATA[NetworkAutomation]]></category><category><![CDATA[streamlit]]></category><category><![CDATA[Jinja2]]></category><category><![CDATA[YAML]]></category><dc:creator><![CDATA[Kgosi Leburu]]></dc:creator><pubDate>Tue, 17 Mar 2026 07:10:07 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/093f3a5c-81fa-4c9f-961b-35dff14b5988.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Network automation has transformed how modern infrastructure teams operate. Scripts that once took hours of manual work — configuring devices, pulling inventory, validating compliance — now execute in seconds. But there is a quite, persistent problem that undermines the full potential of these scripts: only the person who wrote them can use them.</p>
<p>This is the <strong>automation accessibility gap</strong>. A skilled network engineer writes a powerful Python script to query BGP neighbors, generate config diffs, or push bulk ACL changes. The script works beautifully — in their terminal, on their machine, with their environment variables set correctly. But the moment that engineer is unavailable, the capability disappears. Colleagues cannot run it. Managers cannot trigger it. The NOC team, the helpdesk, and even other engineers are locked out</p>
<blockquote>
<p><strong>"The best automation script is useless if only one person can run it."</strong></p>
</blockquote>
<p>The solution is not to simplify the scripts — it is to wrap them in an interface that makes them universally accessible. Web frameworks like <strong>Streamlit</strong> provide this capability, bridging the gap between powerful automation logic and the teams that need to use it every day.</p>
<h1>The Challenge of Terminal-Only Automation</h1>
<p>Before exploring the solution, it's important to understand why terminal-based automation struggles to scale across teams.</p>
<h2><strong>Expertise Dependency</strong></h2>
<p>Running a Python script involves understanding how to use a terminal, navigate directories, manage virtual environments, install dependencies, and set the correct flags and arguments. While network engineers find this routine, it poses a significant barrier for NOC analysts, security auditors, or project managers needing a one-time report. When automation is confined to the terminal, it centralizes power among a few and creates bottlenecks.</p>
<h2><strong>Fragile Environments</strong></h2>
<p>Scripts that run perfectly in one engineer's environment often fail in another's due to different Python versions, missing libraries, conflicting dependencies, or mismatched environment variables. Reproducing the right execution environment is a technical skill unto itself, and it consumes time that teams do not have during incidents or operational windows.</p>
<h2><strong>Auditability Challenges</strong></h2>
<p>When multiple engineers share scripts over email or shared drives, there is no audit trail. Who ran the script? Against which devices? With what parameters? What was the output? This opacity creates compliance risks, troubleshooting challenges, and accountability gaps — especially in regulated environments</p>
<h2><strong>Lack of Validation</strong></h2>
<p>Raw scripts often accept command-line arguments with minimal validation. This can lead to errors, such as passing the wrong subnet, targeting the incorrect device group, or bypassing a confirmation prompt. Without a proper interface, there's no convenient way to implement input validation, confirmation dialogs, or permission controls.</p>
<h1>Introducing Streamlit: Rapid UI for Network Automation</h1>
<p><strong>Streamlit</strong> is an open-source Python framework that allows developers to create web applications directly from Python scripts — no HTML, no JavaScript, no CSS required. For network engineers who already write Python, this is transformative. The same code that powers a script can be surfaced as a clean, interactive web app in a matter of hours</p>
<h2><strong>Why Streamlit is Ideal for Network Teams ?</strong></h2>
<p><strong>Streamlit</strong> was designed with data scientists and engineers in mind, not professional web developers. Its philosophy is that the person who understands the logic should be able to build the interface. For network automation, this is a perfect fit:</p>
<p>•        Pure Python: No new languages to learn. If you can write a Netmiko or NAPALM script, you can build a Streamlit app.</p>
<p>•        Rapid development: A basic interface can be wrapped around an existing script in under an hour.</p>
<p>•        Built-in widgets: Text inputs, dropdowns, file uploaders, progress bars, and data tables are all available with single-line function calls.</p>
<p>•        Real-time output: Streamlit supports streaming output, making it ideal for long-running tasks like multi-device configuration pushes.</p>
<p>•        Easy deployment: Apps run on any server accessible by the team, including VMs, containers, or internal platforms.</p>
<h2>How Streamlit Works (Behind the Scenes)</h2>
<p>Streamlit follows a <strong>script rerun model</strong>.</p>
<p>Every time a user interacts with the UI:</p>
<ul>
<li><p>The Python script reruns from top to bottom</p>
</li>
<li><p>UI state is preserved using <code>session_state</code></p>
</li>
</ul>
<p>Architecture flow:</p>
<pre><code class="language-python">User Interaction (Button / Input)
            ↓
Streamlit Reruns Script
            ↓
Updates UI Components
            ↓
Displays New Results
</code></pre>
<h2>Core Streamlit Components Explained</h2>
<h3>Text Elements</h3>
<pre><code class="language-python">st.title("Network Config Generator")
st.caption("Generate configs using Jinja2")
</code></pre>
<p>Used for headings and descriptions.</p>
<h3>Input Widgets</h3>
<pre><code class="language-python">device = st.text_input("Enter Router IP")
</code></pre>
<p>Common widgets:</p>
<ul>
<li><p>text_input</p>
</li>
<li><p>selectbox</p>
</li>
<li><p>checkbox</p>
</li>
<li><p>sliders</p>
</li>
<li><p>file uploader</p>
</li>
</ul>
<h3>Layout System (Professional Dashboards)</h3>
<pre><code class="language-python">if st.button("Generate Configuration"):
    run_automation()
</code></pre>
<p>Buttons trigger Python logic directly.</p>
<h3>Session State: The Most Important Concept</h3>
<p>Because Streamlit reruns scripts, it uses:</p>
<pre><code class="language-python">st.session_state
</code></pre>
<p>This acts like memory for:</p>
<ul>
<li><p>Templates</p>
</li>
<li><p>User inputs</p>
</li>
<li><p>Generated outputs</p>
</li>
</ul>
<h1>Real-World Use Case for Network Engineers</h1>
<h2>Configuration Generator (Jinja2 + YAML)</h2>
<p>In recent articles, we've discussed generating dynamic configurations with Jinja2 in a multi-vendor environment and the significance of configuration validation. Throughout the series, we developed several scripts for practical use. We will reuse our automation scripts and integrate them into an interactive, user-friendly interface. With Streamlit, you can quickly prototype a functional GUI without needing frontend skills.</p>
<blockquote>
<p>Streamlit transforms a Python script into a self-service tool, empowering teams with independence while maintaining the engineer's control over the underlying logic.</p>
</blockquote>
<h3>Streamlit in a Network Configuration Generator</h3>
<p>A typical architecture:</p>
<pre><code class="language-python">User Input (Template + YAML)
            ↓
Streamlit GUI
            ↓
Jinja2 Rendering Engine
            ↓
Generated Configuration Output
            ↓
Download or Deploy to Devices
</code></pre>
<h3>Comparing Streamlit vs Traditional Web Frameworks</h3>
<table>
<thead>
<tr>
<th><strong>Feature</strong></th>
<th>Streamlit</th>
<th>Flask/Django</th>
</tr>
</thead>
<tbody><tr>
<td>Learning Curve</td>
<td>Very Low</td>
<td>Medium–High</td>
</tr>
<tr>
<td>Frontend Required</td>
<td>No</td>
<td>Yes</td>
</tr>
<tr>
<td>Speed of Development</td>
<td>Very Fast</td>
<td>Slower</td>
</tr>
<tr>
<td>Best Use Case</td>
<td>Internal tools &amp; dashboards</td>
<td>Full-scale web apps</td>
</tr>
</tbody></table>
<h3>Prerequisites</h3>
<pre><code class="language-python">pip install streamlit 
</code></pre>
<h3>Run the application</h3>
<pre><code class="language-python">streamlit run app.py 
</code></pre>
<h3>Try the Correct URL</h3>
<pre><code class="language-python">http://localhost:8501 # On the SAME machine
http://&lt;server-ip&gt;:8501 # If running on a server/VM
</code></pre>
<h3>Generated Output</h3>
<p>Network Configuration Generator UI application</p>
<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/8a793908-d613-42f2-9dad-d24f42d186a7.png" alt="" style="display:block;margin:0 auto" />

<p>Load Nokia SROS example and Validate :</p>
<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/466a5d29-6402-42ca-b1a0-59c48b1f63c7.png" alt="" style="display:block;margin:0 auto" />

<div>
<div>💡</div>
<div>YAML validation successful</div>
</div>

<p>Nokia SROS example Generate Configuration</p>
<img src="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/c6a84abb-2f45-4677-a632-d083df55b948.png" alt="" style="display:block;margin:0 auto" />

<div>
<div>💡</div>
<div>Configuration generated and Download Config</div>
</div>

<h1>Limitations of Streamlit</h1>
<p>While Streamlit is excellent for internal tools and dashboards, it is not designed for large-scale enterprise web platforms requiring complex authentication, microservices, or highly customized frontends. However, for automation GUIs and engineering tools, it is one of the most efficient solutions available.</p>
<h1><strong>Conclusion: Automation is a Team Sport</strong></h1>
<p>Network automation has historically been the domain of individual engineers writing scripts for personal use. Streamlit change that equation entirely. It provide the bridge between powerful automation logic and the diverse teams that need to act on it — without requiring every user to become a Python developer or a terminal power user.</p>
<p>Network automation is only truly effective when it is accessible to everyone. A script limited to a single user is a capability restricted to that individual. In contrast, a web application that any team member can utilize becomes a resource for the entire organization.</p>
<blockquote>
<p><strong>Build the script. Then build the interface. Automation is only as powerful as its reach.</strong></p>
</blockquote>
]]></content:encoded></item><item><title><![CDATA[Configuration Validation and Testing – Safe Network Changes in a Multi-Vendor Environment]]></title><description><![CDATA[Generating configurations with Jinja is powerful. Deploying them safely is engineering.

We explored how to use Jinja2 templates to standardize configurations across vendors. But generating configurat]]></description><link>https://codednetwork.com/configuration-validation-and-testing-safe-network-changes-in-a-multi-vendor-environment</link><guid isPermaLink="true">https://codednetwork.com/configuration-validation-and-testing-safe-network-changes-in-a-multi-vendor-environment</guid><category><![CDATA[Jinja2]]></category><category><![CDATA[Python]]></category><category><![CDATA[YAML]]></category><category><![CDATA[yang]]></category><category><![CDATA[NetworkAutomation]]></category><category><![CDATA[template]]></category><dc:creator><![CDATA[Kgosi Leburu]]></dc:creator><pubDate>Tue, 10 Mar 2026 07:19:15 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/68933f4690103a1d4a8d7df7/030acdda-5319-4441-9001-1ed35d170e47.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p>Generating configurations with Jinja is powerful. Deploying them safely is engineering.</p>
</blockquote>
<p>We explored how to use <strong>Jinja2 templates</strong> to standardize configurations across vendors. But generating configuration is only half the job.</p>
<p>The real question is:</p>
<blockquote>
<p>How do you make sure your generated configuration won’t break production?</p>
</blockquote>
<p>Before you push any configuration to production, you need to know if it's correct. One typo can bring down a network. One misconfigured route can create a routing loop. One wrong ACL can block critical traffic.</p>
<p><strong>The golden rule of network automation</strong>: Never deploy untested configurations.</p>
<h1>Why Configuration Validation Matters ?</h1>
<p>Network outages can be extremely costly for enterprises. Most unplanned outages result from human errors during configuration changes rather than hardware failures. Implementing a disciplined pre- and post-change validation workflow significantly reduces this risk by automating the comparison of network states before and after each change window.</p>
<p>In a multi-vendor environment, syntax and behavior differ:</p>
<table>
<thead>
<tr>
<th>Vendor</th>
<th>Interface Syntax</th>
<th>Commit Model</th>
<th>Validation Behavior</th>
</tr>
</thead>
<tbody><tr>
<td>Cisco IOS-XR</td>
<td><code>interface GigabitEthernet0/0/0/0</code></td>
<td>Commit-based</td>
<td>Fails at commit</td>
</tr>
<tr>
<td>Junos</td>
<td><code>set interfaces ge-0/0/0</code></td>
<td>Candidate + Commit</td>
<td>Commit check available</td>
</tr>
<tr>
<td>Nokia SROS</td>
<td><code>/configure router interface &lt;name&gt; port 1/1/c1/1</code></td>
<td>Candidate + Commit</td>
<td>Validate check available</td>
</tr>
</tbody></table>
<div>
<div>💡</div>
<div><em>Key insight: You cannot know whether a change went wrong unless you know exactly what the network looked like before the change was applied.</em></div>
</div>

<h2>Real Case 1: Interface Description Template Gone Wrong</h2>
<h3>Scenario</h3>
<p>You generate this Jinja template:</p>
<pre><code class="language-python">interface {{ interface_name }}
 description {{ description }}
 ip address {{ ip_address }} {{ mask }}
</code></pre>
<p>It works for:</p>
<ul>
<li>Cisco IOS-XR</li>
</ul>
<p>But your Junos device expects:</p>
<pre><code class="language-python">set interfaces ge-0/0/0 description "Uplink to Core"
set interfaces ge-0/0/0 unit 0 family inet address 10.1.1.1/24
</code></pre>
<h2>Real Case 2: BGP Policy Mismatch Across Vendors</h2>
<h3>Scenario</h3>
<p>You template BGP policies for 50 devices.</p>
<p>Your data model:</p>
<pre><code class="language-python">local_as: 65001
neighbor: 10.0.0.2
remote_as: 65002 
</code></pre>
<h3>Problem</h3>
<p>On IOS-XR:</p>
<pre><code class="language-python">router bgp 65001
 neighbor 10.0.0.2
  remote-as 65002
</code></pre>
<p>On Junos:</p>
<pre><code class="language-python">set protocols bgp group EBGP neighbor 10.0.0.2 peer-as 65002
</code></pre>
<p>But your automation mistakenly renders:</p>
<pre><code class="language-python">remote-as 65001
</code></pre>
<h3>Result?</h3>
<ul>
<li><p>Session never comes up</p>
</li>
<li><p>No routing exchange</p>
</li>
<li><p>Silent failure</p>
</li>
</ul>
<h2>Types of Validation</h2>
<ol>
<li><h3>Template Validation</h3>
</li>
</ol>
<p>Before pushing configurations, validate:</p>
<ul>
<li><p>YAML variables</p>
</li>
<li><p>Required fields</p>
</li>
<li><p>Data structure integrity</p>
</li>
</ul>
<p><strong>Example Python Validation</strong></p>
<pre><code class="language-python">import yaml
from jinja2 import Template, TemplateError

errors = []

def validate(data_text, template_text):
    try:
        yaml.safe_load(data_text)
    except yaml.YAMLError as e:
        errors.append(f"YAML error: {e}")

    try:
        Template(template_text)
    except TemplateError as e:
        errors.append(f"Template error: {e}")

    return errors
</code></pre>
<ol>
<li><h3>Syntax Validation</h3>
</li>
</ol>
<p>Each vendor supports some form of pre-check.</p>
<p><strong>Nokia SROS Model Driven - Validate</strong></p>
<pre><code class="language-plaintext">validate
</code></pre>
<p><strong>Junos – Commit Check</strong></p>
<pre><code class="language-python">commit check 
</code></pre>
<p><strong>Cisco IOS-XR – Commit Replace (Validate Before Apply)</strong></p>
<pre><code class="language-python">commit replace
</code></pre>
<ol>
<li><h3>Logical Validation</h3>
</li>
</ol>
<p>Syntax may pass. But logic may be wrong.</p>
<p>Examples:</p>
<ul>
<li><p>Local AS equals Remote AS in eBGP</p>
</li>
<li><p>IP address overlaps existing subnet</p>
</li>
<li><p>Duplicate loopback</p>
</li>
<li><p>MTU mismatch across link</p>
</li>
</ul>
<p><strong>Example Logical Check in Python</strong></p>
<pre><code class="language-python">if data["local_as"] == data["remote_as"]:
    raise ValueError("Local AS and Remote AS cannot be equal in eBGP")
</code></pre>
<ol>
<li><h3>Pre- and Post-Change Checks</h3>
</li>
</ol>
<p>Before applying config:</p>
<ul>
<li><p>Is interface already configured?</p>
</li>
<li><p>Does BGP session already exist?</p>
</li>
<li><p>Is policy already attached?</p>
</li>
<li><p>Is ISIS adjacency healthy?</p>
</li>
</ul>
<p>This ensures:</p>
<blockquote>
<p>One must avoid implementing new changes on an already broken network.</p>
</blockquote>
<p>After applying config:</p>
<ul>
<li><p>Check BGP state = Established</p>
</li>
<li><p>Check ISIS adjacency = Up</p>
</li>
<li><p>Check route present in RIB</p>
</li>
<li><p>Ping test</p>
</li>
</ul>
<h1>Safe Change Workflow in Multi-Vendor Networks</h1>
<p>Here's the workflow:</p>
<pre><code class="language-python">          Inventory Data (YAML)
                    │
            Jinja Rendering
                    │
         Template + YAML Validation
                    │
          Vendor Syntax Check
                    │
          Logical Validation
                    │
          Pre-State Snapshot
                    │
              Deployment
                    │
          Post-State Verification
                    │
            Auto Rollback (if fail)
</code></pre>
<h1><strong>Config Builder Tool</strong></h1>
<h2>About this Project</h2>
<p>I began my network automation journey in 2018, seeking more efficient ways to perform my job. Initially, everything was manual—logging into devices individually, typing <code>show</code> commands, copying configurations, and hoping nothing would break during a 2 AM change window. Then, I discovered a course by David Bombal "<strong>Python Network Programming for Network Engineers</strong>", which transformed my approach, and I haven't looked back since.</p>
<p>The <strong>Config Builder Tool</strong> was one of my first complete project, allowing me to demonstrate to my colleagues the power of <em>network automation.</em> This project is designed to automate the generation of network configurations using Python and Jinja2. The tool has been tested with Nokia SROS routers running releases 22.10.R1 and 24.10.R5, operating in model-driven mode. It utilizes NAPALM (<strong>Network Automation and Programmability Abstraction Layer with Multivendor support</strong>) to automate interactions with network devices via NETCONF.</p>
<p>NAPALM offers a configuration method called <code>compare_config</code>, which compares the candidate and running configurations on the SROS target. This is useful for <strong>pre-checking</strong> before deploying any configurations. This tool is interactive the user will be given a prompt to apply the configurations if required.</p>
<h2>Prerequisites</h2>
<h3>Step 1</h3>
<p>Enable MD-CLI on the Nokia SR OS network Device</p>
<pre><code class="language-plaintext">A:R1# /configure system management-interface cli md-cli auto-config-save
A:R1# /configure system management-interface configuration-mode model-driven
</code></pre>
<h3>Step 2</h3>
<p>Enable NETCONF on the Nokia SR OS network Device</p>
<pre><code class="language-plaintext">(gl)[configure system management-interface]
A:admin@R1#
    netconf {
        admin-state enable
        auto-config-save true
    }
</code></pre>
<p>Select YANG models to use on the Nokia SR OS network Device</p>
<pre><code class="language-plaintext">(gl)[configure system management-interface]
A:admin@R1#
    yang-modules {
       nokia-modules false
       nokia-combined-modules true
    }
</code></pre>
<p>Select NETCONF user and permissions</p>
<pre><code class="language-plaintext">(gl)[configure local-user system security user-params]
A:admin@R1#
 {
        user “admin" {
            password “admin"
            access {
                netconf true
            }
            console {
                member ["administrative"]
            }
        }
    }
</code></pre>
<h3>Step 3</h3>
<p>Before you begin, ensure you have the following installed:</p>
<ol>
<li><p>Python 3.x</p>
</li>
<li><p>NAPALM library</p>
</li>
<li><p>PyYAML and Jinja2 libraries</p>
</li>
</ol>
<h2>Repository Structure</h2>
<pre><code class="language-plaintext">├── vars.yml
├── builder.py
└── servicevpls.xml.j2
</code></pre>
<h2>Usage</h2>
<ol>
<li><strong>File</strong>: <code>vars.yml</code></li>
</ol>
<pre><code class="language-python">vpls:
  - { name: demo_vpls1, id: 80, saps: [1/1/c3/2:15 , 1/1/c3/3:16] }
  - { name: demo_vpls2, id: 90, saps: [] }
  - { name: demo_vpls3, id: 100, saps: ["1/1/c3/4:17", "1/1/c4/2:18", "1/1/c4/2:19"] }
auto:
  start: 2000000000
  end: 2147483647
</code></pre>
<ol>
<li><strong>File</strong>: <code>servicevpls.xmls.j2</code></li>
</ol>
<pre><code class="language-python">&lt;configure xmlns="urn:nokia.com:sros:ns:yang:sr:conf"&gt;
        &lt;service&gt;
            &lt;customer&gt;
                &lt;customer-name&gt;demo&lt;/customer-name&gt;
                &lt;description&gt;NETCONF L2VPN demo Nokia SR OS&lt;/description&gt;
                &lt;contact&gt;DEVOps Team&lt;/contact&gt;
            &lt;/customer&gt;
            &lt;md-auto-id&gt;
                &lt;service-id-range&gt;
                    &lt;start&gt;{{ auto.start }}&lt;/start&gt;
                    &lt;end&gt;{{ auto.end }}&lt;/end&gt;
                &lt;/service-id-range&gt;
                &lt;customer-id-range&gt;
                    &lt;start&gt;{{ auto.start }}&lt;/start&gt;
                    &lt;end&gt;{{ auto.end }}&lt;/end&gt;
                &lt;/customer-id-range&gt;
            &lt;/md-auto-id&gt;
{% for svc in vpls %}
          &lt;vpls&gt;
            &lt;service-name&gt;{{ svc.name }}&lt;/service-name&gt;
            &lt;customer&gt;demo&lt;/customer&gt;
            &lt;admin-state&gt;enable&lt;/admin-state&gt;
{% for sap in svc.saps %}
            &lt;sap&gt;
              &lt;sap-id&gt;{{ sap }}&lt;/sap-id&gt;
              &lt;admin-state&gt;enable&lt;/admin-state&gt;
            &lt;/sap&gt;
{% endfor %}
          &lt;/vpls&gt;
{% endfor %}
        &lt;/service&gt;
&lt;/configure&gt;
</code></pre>
<ol>
<li><strong>Main Script</strong></li>
</ol>
<pre><code class="language-python">import sys
import json
from jinja2 import Environment, FileSystemLoader
from napalm import get_network_driver

#Import YAML from PyYAML
import yaml

def configbuilder ():
    print('''

****************************************************
CONFIG BUILDER TOOL
****************************************************
    ''')

configbuilder()

if len(sys.argv) == 3:
    #Load data from YAML file into Python dictionary
    config = yaml.load(open(sys.argv[1]), Loader=yaml.FullLoader)

    #Load Jinja2 template
    env = Environment(loader = FileSystemLoader('./'), trim_blocks=True, lstrip_blocks=True)
    template = env.get_template(sys.argv[2])

    #Render template using data and print the output
    print('''

****************************************************
PRE-LOADED CONFIGS
****************************************************
    
    ''')
    print(template.render(config))
    
else:

    print("Usage: python3 builder.py &lt;data_yml_file&gt; &lt;jinja_template_file&gt;")
    print()
    print()
    sys.exit();
    
# Return template with data and store it into variable

response = template.render(config)

# Connect to the router and merge config

hostname = '172.20.20.13'
username = 'username'
password = 'password'

def connect ():
   """ This function is used to connect into the network device and prompt a user to commit or discard changes """
   driver = get_network_driver('sros')
   device = driver(hostname=hostname, username=username, password=password)
   device.open()
   device.load_merge_candidate(config=response)

   print()
   print()
   print('''

***************************************************************************
COMPARE AND LOAD CONFIGS
***************************************************************************
         ''')

   print("Results will be displayed when the is a DIFFERENCE between running and candidate configurations ")
   print()
   print("No Results will be displayed if running and candidate have the same configurations")
   print()
   print(device.compare_config())
   print()
   # for json format use the below option
   #print(device.compare_config('json_format':True))
     
   try:
       choice = input("\nWould you like to commit these changes? [y or N]: ")
   except NameError:
       choice = input("\nWould you like to commit these changes? [y or N]: ")
    
   if choice == "y":
       print()
       print(f"Committing the configurations on router {hostname} as {username}")
       print()
       device.commit_config()
   else:
       print()
       print(f"Discarding and not applying the configurations on router {hostname} as {username}")
       print()
       device.discard_config()
       
connect()
</code></pre>
<ol>
<li><strong>Expected Output</strong></li>
</ol>
<p>Rendered configuration template</p>
<pre><code class="language-python">****************************************************
PRE-LOADED CONFIGS
****************************************************


&lt;configure xmlns="urn:nokia.com:sros:ns:yang:sr:conf"&gt;
        &lt;service&gt;
            &lt;customer&gt;
                &lt;customer-name&gt;demo&lt;/customer-name&gt;
                &lt;description&gt;NETCONF L2VPN demo Nokia SR OS&lt;/description&gt;
                &lt;contact&gt;DEVOps Team&lt;/contact&gt;
            &lt;/customer&gt;
            &lt;md-auto-id&gt;
                &lt;service-id-range&gt;
                    &lt;start&gt;2000000000&lt;/start&gt;
                    &lt;end&gt;2147483647&lt;/end&gt;
                &lt;/service-id-range&gt;
                &lt;customer-id-range&gt;
                    &lt;start&gt;2000000000&lt;/start&gt;
                    &lt;end&gt;2147483647&lt;/end&gt;
                &lt;/customer-id-range&gt;
            &lt;/md-auto-id&gt;
          &lt;vpls&gt;
            &lt;service-name&gt;demo_vpls1&lt;/service-name&gt;
            &lt;customer&gt;demo&lt;/customer&gt;
            &lt;admin-state&gt;enable&lt;/admin-state&gt;
            &lt;sap&gt;
              &lt;sap-id&gt;1/1/c1/2:15&lt;/sap-id&gt;
              &lt;admin-state&gt;enable&lt;/admin-state&gt;
            &lt;/sap&gt;
            &lt;sap&gt;
              &lt;sap-id&gt;1/1/c1/3:16&lt;/sap-id&gt;
              &lt;admin-state&gt;enable&lt;/admin-state&gt;
            &lt;/sap&gt;
          &lt;/vpls&gt;
          &lt;vpls&gt;
            &lt;service-name&gt;demo_vpls2&lt;/service-name&gt;
            &lt;customer&gt;demo&lt;/customer&gt;
            &lt;admin-state&gt;enable&lt;/admin-state&gt;
          &lt;/vpls&gt;
          &lt;vpls&gt;
            &lt;service-name&gt;demo_vpls3&lt;/service-name&gt;
            &lt;customer&gt;demo&lt;/customer&gt;
            &lt;admin-state&gt;enable&lt;/admin-state&gt;
            &lt;sap&gt;
              &lt;sap-id&gt;1/1/c1/4:17&lt;/sap-id&gt;
              &lt;admin-state&gt;enable&lt;/admin-state&gt;
            &lt;/sap&gt;
            &lt;sap&gt;
              &lt;sap-id&gt;1/1/c2/2:18&lt;/sap-id&gt;
              &lt;admin-state&gt;enable&lt;/admin-state&gt;
            &lt;/sap&gt;
            &lt;sap&gt;
              &lt;sap-id&gt;1/1/c2/3:19&lt;/sap-id&gt;
              &lt;admin-state&gt;enable&lt;/admin-state&gt;
            &lt;/sap&gt;
          &lt;/vpls&gt;
        &lt;/service&gt;
&lt;/configure&gt;
</code></pre>
<p>Comparison candidate vs running configuration</p>
<div>
<div>💡</div>
<div>The is no L2VPN (VPLS) services on this router therefore you will see only the new addition</div>
</div>

<pre><code class="language-python">***************************************************************************
COMPARE AND LOAD CONFIGS

***************************************************************************

Results will be displayed when the is a DIFFERENCE between running and candidate configurations

No Results will be displayed if running and candidate have the same configurations

[
    "add",
    "configure.service",
    [
        [
            "customer",
            {
                "contact": "DEVOps Team",
                "customer-name": "demo",
                "description": "NETCONF L2VPN demo Nokia SR OS"
            }
        ],
        [
            "md-auto-id",
            {
                "customer-id-range": {
                    "end": "2147483647",
                    "start": "2000000000"
                },
                "service-id-range": {
                    "end": "2147483647",
                    "start": "2000000000"
                }
            }
        ],
        [
            "vpls",
            [
                {
                    "admin-state": "enable",
                    "customer": "demo",
                    "sap": [
                        {
                            "admin-state": "enable",
                            "sap-id": "1/1/c1/2:15"
                        },
                        {
                            "admin-state": "enable",
                            "sap-id": "1/1/c1/3:16"
                        }
                    ],
                    "service-name": "demo_vpls1"
                },
                {
                    "admin-state": "enable",
                    "customer": "demo",
                    "service-name": "demo_vpls2"
                },
                {
                    "admin-state": "enable",
                    "customer": "demo",
                    "sap": [
                        {
                            "admin-state": "enable",
                            "sap-id": "1/1/c1/4:17"
                        },
                        {
                            "admin-state": "enable",
                            "sap-id": "1/1/c2/2:18"
                        },
                        {
                            "admin-state": "enable",
                            "sap-id": "1/1/c2/3:19"
                        }
                    ],
                    "service-name": "demo_vpls3"
                }
            ]
        ]
    ]
]
</code></pre>
<p>Device commit prompt</p>
<pre><code class="language-python">Would you like to commit these changes? [y or N]:

Would you like to commit these changes? [y or N]: N

Discarding and not applying the configurations on router 172.20.20.13 as admin
</code></pre>
<h1>Key Takeaways</h1>
<ul>
<li><p>Never deploy untested configurations</p>
</li>
<li><p>Use multiple validation layers</p>
</li>
<li><p>Always take pre-deployment backups</p>
</li>
<li><p>Capture pre/post state</p>
</li>
<li><p>Have rollback procedures ready</p>
</li>
<li><p>Use vendor-native commit checks</p>
</li>
</ul>
<h1>Download the Code</h1>
<p>All templates and script: <a href="https://gitlab.com/kgosileburu/configbuilder-tool">configbuildertool</a></p>
<h1>Final Thoughts</h1>
<blockquote>
<p>If Jinja gives you power…</p>
<p>Validation gives you safety.</p>
</blockquote>
]]></content:encoded></item><item><title><![CDATA[Mastering Dynamic Configurations: A Beginner's Guide to Jinja2 - Part 2]]></title><description><![CDATA[Having completed Part 1, you now understand Jinja2 and its basic templating constructs, including variables, control structures, and simple filters for generating dynamic text. In Part 2, we will delv]]></description><link>https://codednetwork.com/mastering-dynamic-configurations-a-beginner-s-guide-to-jinja2-part-2</link><guid isPermaLink="true">https://codednetwork.com/mastering-dynamic-configurations-a-beginner-s-guide-to-jinja2-part-2</guid><dc:creator><![CDATA[Kgosi Leburu]]></dc:creator><pubDate>Tue, 03 Mar 2026 07:52:26 GMT</pubDate><enclosure url="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/68933f4690103a1d4a8d7df7/dd1571d3-13a0-4248-8e42-16811e1f83b8.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Having completed Part 1, you now understand Jinja2 and its basic templating constructs, including variables, control structures, and simple filters for generating dynamic text. In Part 2, we will delve deeper into practical techniques that make Jinja2 a powerful tool for configuration management. You can look forward to detailed, practical examples on creating maintainable, reusable templates for router configurations..</p>
<h2>Use Case 1: Nokia SR OS Service Configuration</h2>
<h3>Scenario</h3>
<p>You are deploying 50 new customer L3VPN services on Nokia SR OS routers. Each customer requires:</p>
<ul>
<li><p>Unique customer ID</p>
</li>
<li><p>Service description</p>
</li>
<li><p>IP interfaces</p>
</li>
<li><p>BGP Peering</p>
</li>
</ul>
<h3>Prerequisites</h3>
<pre><code class="language-python">pip install jinja2
pip install pyyaml 
pip install netmiko #For deployment (optional)

# Install required libraries t 
# It is always essential create a python environment
</code></pre>
<h3>Jinja2 Template</h3>
<p><strong>File</strong>: <code>nokia_customer_service.j2</code></p>
<pre><code class="language-python">{# Nokia SR OS Customer Service Template #}
{# =================================== #}
{# Customer: {{ customer.name }} #}
{# Date: {{ timestamp }} #}
{# =================================== #}

/configure
#--------------------------------------------------
echo "Creating Customer {{ customer.id }}: {{ customer.name }}"
#--------------------------------------------------

    service
        customer {{ customer.id }} create
            description "{{ customer.name }}"
        exit
        
        {% for vprn in customer.vprns %}
        vprn {{ vprn.service_id }} customer {{ customer.id }} create
            service-name "{{ vprn.name }}"
            description "{{ vprn.description }}"
            autonomous-system {{ vprn.as_number }}
            route-distinguisher {{ vprn.rd }}
            
            {# Configure VPRN Interfaces #}
            {% for interface in vprn.interfaces %}
            interface "{{ interface.name }}" create
                address {{ interface.ip }}/{{ interface.prefix }}
                sap {{ interface.sap }} create
                    description "{{ interface.description }}"
                exit
            exit
            {% endfor %}
            
            {# Configure BGP if present #}
            {% if vprn.bgp %}
            bgp
                group "{{ vprn.bgp.group_name }}"
                    type {{ vprn.bgp.type }}
                    peer-as {{ vprn.bgp.peer_as }}
                    
                    {% for neighbor in vprn.bgp.neighbors %}
                    neighbor {{ neighbor.ip }}
                        description "{{ neighbor.description }}"
                    exit
                    {% endfor %}
                exit
            exit
            {% endif %}
            
            no shutdown
        exit
        {% endfor %}
    exit
exit
</code></pre>
<h3>Data YAML file</h3>
<p><strong>File:</strong> <code>customers_data.yaml</code></p>
<pre><code class="language-yaml">---
customers:
  - id: 100
    name: CodedNetwork Corp
    vprns:
      - service_id: 1000
        name: CodedN-CORP-VPRN
        description: Enterprise Corp Main VPRN
        as_number: 65000
        rd: 65000:100
        interfaces:
          - name: to-customer-site-1
            ip: 10.100.1.1
            prefix: 30
            sap: 1/1/c1/1:100
            description: Customer Site 1 Connection
          - name: to-customer-site-2
            ip: 10.100.2.1
            prefix: 30
            sap: 1/1/c2/1:101
            description: Customer Site 2 Connection
        bgp:
          group_name: customer-bgp
          type: external
          peer_as: 65100
          neighbors:
            - ip: 10.100.1.2
              description: Site 1 CE Router
            - ip: 10.100.2.2
              description: Site 2 CE Router
  - id: 101
    name: Tech Startup Inc
    vprns:
      - service_id: 1001
        name: TECH-STARTUP-VPRN
        description: Tech Startup Main VPRN
        as_number: 65001
        rd: 65000:101
        interfaces:
          - name: to-startup-hq
            ip: 10.101.1.1
            prefix: 30
            sap: 1/1/c3/1:200
            description: Startup HQ Connection
        bgp:
          group_name: startup-bgp
          type: external
          peer_as: 65200
          neighbors:
            - ip: 10.101.1.2
              description: HQ CE Router
</code></pre>
<h2>Nokia SR OS Configuration Generator - Code Overview</h2>
<pre><code class="language-python">#!/usr/bin/env python3
"""
Nokia SR OS Configuration Generator using Jinja2
Generates customer service configurations from YAML data
"""

from jinja2 import Environment, FileSystemLoader
import yaml
from datetime import datetime
import os

def load_data(yaml_file):
    """Load customer data from YAML file"""
    with open(yaml_file, 'r') as f:
        return yaml.safe_load(f)

def generate_config(template_file, data, output_dir='configs_generated'):
    """
    Generate configurations from template and data
    
    Args:
        template_file: Jinja2 template file path
        data: Dictionary with customer data
        output_dir: Directory to save generated configs
    """
    
    # Create output directory
    if not os.path.exists(output_dir):
        os.makedirs(output_dir)
    
    # Set up Jinja2 environment
    env = Environment(
        loader=FileSystemLoader('.'),
        trim_blocks=True,
        lstrip_blocks=True
    )
    
    # Load template
    template = env.get_template(template_file)
    
    # Generate config for each customer
    for customer in data['customers']:
        # Add timestamp to data
        customer['timestamp'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
        
        # Render template with customer data
        config = template.render(customer=customer)
        
        # Create filename
        filename = f"{output_dir}/nokia_customer_{customer['id']}_{customer['name'].replace(' ', '_')}.cfg"
        
        # Save to file
        with open(filename, 'w') as f:
            f.write(config)
        
        print(f"✓ Generated: {filename}")
        print(f"  Customer: {customer['name']} (ID: {customer['id']})")
        print(f"  VPRNs: {len(customer['vprns'])}")
        print()

def main():
    """Main execution"""
    print("="*70)
    print("Nokia SR OS Configuration Generator")
    print("="*70)
    print()
    
    # Load data
    print("Loading customer data...")
    data = load_data('customers_data.yaml')
    print(f"✓ Loaded {len(data['customers'])} customers")
    print()
    
    # Generate configurations
    print("Generating configurations...")
    print()
    generate_config('nokia_customer_service.j2', data)
    
    print("="*70)
    print("Generation complete!")
    print("="*70)

if __name__ == "__main__":
    main()
</code></pre>
<h2>Detailed Step-by-Step Explanation</h2>
<ol>
<li>Imports and Setup</li>
</ol>
<pre><code class="language-python">from jinja2 import Environment, FileSystemLoader
import yaml
from datetime import datetime
import os
</code></pre>
<p><strong>Purpose of each Import</strong></p>
<ul>
<li><p><strong>Jinja2</strong> : Template engine for generating text files</p>
</li>
<li><p><strong>YAML</strong> : Reads structured customer data</p>
</li>
<li><p><strong>datetime</strong> : Adds Timestamps to configs</p>
</li>
<li><p><strong>os</strong> : Creates directories for output files</p>
</li>
</ul>
<ol>
<li><code>load_data()</code> Function</li>
</ol>
<pre><code class="language-python">def load_data(yaml_file):
    with open(yaml_file, 'r') as f:
        return yaml.safe_load(f)

# The function reads a YAML file and returns its contents as a Python dictionary
</code></pre>
<ol>
<li><code>generate_config()</code> Function</li>
</ol>
<pre><code class="language-python">if not os.path.exists(output_dir):
    os.makedirs(output_dir)

# Creates a folder called `generated_configs` if it doesn't exist
</code></pre>
<pre><code class="language-python">env = Environment(
    loader=FileSystemLoader('.'),
    trim_blocks=True,
    lstrip_blocks=True
)

# Sets up template engine to look for templates in current directory
#`trim_blocks` and `lstrip_blocks` remove extra whitespace for cleaner output
</code></pre>
<pre><code class="language-python">template = env.get_template(template_file 

# Loads the Jinja2 template file (contains Nokia router config structure with placeholders)
</code></pre>
<pre><code class="language-python">for customer in data['customers']:
    customer['timestamp'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
    config = template.render(customer=customer)

# Loops through each customer in the YAML data
# Adds current timestamp to customer data
# Renders the template by replacing placeholders with actual customer values
</code></pre>
<pre><code class="language-python">filename = f"{output_dir}/nokia_customer_{customer['id']}_{customer['name'].replace(' ', '_')}.cfg"
with open(filename, 'w') as f:
    f.write(config)

# Writes the rendered configuration to file
</code></pre>
<ol>
<li><code>main ()</code> Function</li>
</ol>
<pre><code class="language-python">def main():
    data = load_data('customers_data.yaml')
    generate_config('nokia_customer_service.j2', data)

# Loads customer data from YAML file
# Generates all configuration files
</code></pre>
<h3>To run the script</h3>
<pre><code class="language-python">python3 nokia_config_generator.py 
</code></pre>
<h3>Output:</h3>
<pre><code class="language-python">python3 nokia_config_generator.py 

======================================================================
Nokia SR OS Configuration Generator
======================================================================

Loading customer data...
✓ Loaded 2 customers

Generating configurations...

✓ Generated: configs_generated/nokia_customer_100_CodedNetwork_Corp.cfg
  Customer: CodedNetwork Corp (ID: 100)
  VPRNs: 1

✓ Generated: configs_generated/nokia_customer_101_Tech_Startup_Inc.cfg
  Customer: Tech Startup Inc (ID: 101)
  VPRNs: 1

======================================================================
Generation complete!
======================================================================

#generated configuration under configs_generated folder 

ls -l
nokia_customer_100_CodedNetwork_Corp.cfg
nokia_customer_101_Tech_Startup_Inc.cfg
</code></pre>
<h3>Generated Configuration Example</h3>
<p><strong>File:</strong> <code>nokia_customer_100_CodedNetwork_Corp.cfg</code></p>
<pre><code class="language-python">cat nokia_customer_100_CodedNetwork_Corp.cfg 

/configure
#--------------------------------------------------
echo "Creating Customer 100: CodedNetwork Corp"
#--------------------------------------------------

    service
        customer 100 create
            description "CodedNetwork Corp"
        exit
        
        vprn 1000 customer 100 create
            service-name "CodedN-CORP-VPRN"
            description "Enterprise Corp Main VPRN"
            autonomous-system 65000
            route-distinguisher 65000:100
            
            interface "to-customer-site-1" create
                address 10.100.1.1/30
                sap 1/1/c1/1:100 create
                    description "Customer Site 1 Connection"
                exit
            exit
            interface "to-customer-site-2" create
                address 10.100.2.1/30
                sap 1/1/c2/1:101 create
                    description "Customer Site 2 Connection"
                exit
            exit
            
            bgp
                group "customer-bgp"
                    type external
                    peer-as 65100
                    
                    neighbor 10.100.1.2
                        description "Site 1 CE Router"
                    exit
                    neighbor 10.100.2.2
                        description "Site 2 CE Router"
                    exit
                exit
            exit
            
            no shutdown
        exit
    exit
	
</code></pre>
<h2>Use Case 2: Nokia SR OS Interface Configuration</h2>
<h3>Scenario</h3>
<p>Configure 48 access ports on a Nokia 7750 SR with:</p>
<ul>
<li><p>Different VLAN assignments</p>
</li>
<li><p>Port descriptions based on location</p>
</li>
<li><p>Speed and duplex settings</p>
</li>
</ul>
<h3>Jinja2 Template</h3>
<p><strong>File</strong>: <code>nokia_interface_config.j2</code></p>
<pre><code class="language-python">{# Nokia SR OS Interface Configuration Template #}
/configure
{% for interface in interfaces %}
    port {{ interface.port }} create
        description "{{ interface.description }}"
        ethernet
            mode {{ interface.mode }}
            {% if interface.speed %}
            speed {{ interface.speed }}
            {% endif %}
            {% if interface.duplex %}
            duplex {{ interface.duplex }}
            {% endif %}
            encap-type {{ interface.encap_type|default('dot1q') }}
        exit
        no shutdown
    exit
{% endfor %}
exit
</code></pre>
<h3>Data YAML file</h3>
<p><strong>File:</strong> <code>interfaces_data.yaml</code></p>
<pre><code class="language-python">interfaces:
  - port: "1/1/c1/1"
    description: "Building A - Floor 1 - Room 101"
    mode: "access"
    speed: "1000"
    duplex: "full"
    vlan: 100
    
  - port: "1/1/c2/1"
    description: "Building A - Floor 1 - Room 102"
    mode: "access"
    speed: "1000"
    duplex: "full"
    vlan: 100
    
  - port: "1/1/c3/1"
    description: "Building A - Floor 2 - Conference Room"
    mode: "access"
    speed: "1000"
    duplex: "full"
    vlan: 200
    
  - port: "1/1/c4/1"
    description: "Uplink to Core Switch"
    mode: "network"
    speed: "10000"
    encap_type: "qinq"
</code></pre>
<h3>Output:</h3>
<pre><code class="language-python">python3 nokia_config_interface_gen.py 

======================================================================
Nokia SR OS Configuration Generator
======================================================================

Loading interface data...
✓ Loaded 4 interfaces

Generating configurations...

✓ Generated: configs_generated/nokia_interface_Building_A_-_Floor_1_-_Room_101.cfg

✓ Generated: configs_generated/nokia_interface_Building_A_-_Floor_1_-_Room_102.cfg

✓ Generated: configs_generated/nokia_interface_Building_A_-_Floor_2_-_Conference_Room.cfg

✓ Generated: configs_generated/nokia_interface_Uplink_to_Core_Switch.cfg

======================================================================
Generation complete!
======================================================================
</code></pre>
<h2>Use Case 3: Multi-vendor BGP Configuration</h2>
<h3>Scenario</h3>
<p>Generate BGP configuration for Nokia SR OS, Cisco IOS XR, and Juniper routers.</p>
<h3>Nokia SROS Template</h3>
<p><strong>File:</strong> <code>nokia_bgp.j2</code></p>
<pre><code class="language-python">/configure
    router bgp
        autonomous-system {{ bgp.local_as }}
        {% for neighbor in bgp.neighbors %}
        neighbor {{ neighbor.ip }}
            peer-as {{ neighbor.remote_as }}
            description "{{ neighbor.description }}"
            family ipv4
            {% if neighbor.password %}
            authentication-key "{{ neighbor.password }}"
            {% endif %}
        exit
        {% endfor %}
    exit
exit
</code></pre>
<h3>Cisco IOS XR Template</h3>
<p><strong>File:</strong> <code>cisco_bgp.j2</code></p>
<pre><code class="language-python">router bgp {{ bgp.local_as }}
{% for neighbor in bgp.neighbors %}
 neighbor {{ neighbor.ip }}
  remote-as {{ neighbor.remote_as }}
  description {{ neighbor.description }}
  address-family ipv4 unicast
  !
  {% if neighbor.password %}
  password encrypted {{ neighbor.password }}
  {% endif %}
 !
{% endfor %}
!
</code></pre>
<h3>Juniper Template</h3>
<p><strong>File:</strong> <code>juniper_bgp.j2</code></p>
<pre><code class="language-python">protocols {
    bgp {
        group external {
            type external;
            local-as {{ bgp.local_as }};
            {% for neighbor in bgp.neighbors %}
            neighbor {{ neighbor.ip }} {
                description "{{ neighbor.description }}";
                peer-as {{ neighbor.remote_as }};
                {% if neighbor.password %}
                authentication-key "{{ neighbor.password }}";
                {% endif %}
            }
            {% endfor %}
        }
    }
}
</code></pre>
<h3>Unified YAML data file</h3>
<p><strong>File:</strong> <code>bgp_data.yaml</code></p>
<pre><code class="language-python">bgp:
  local_as: 65000
  neighbors:
    - ip: "10.1.1.1"
      remote_as: 65001
      description: "Peer to ISP1"
      password: "secret123"
    
    - ip: "10.1.1.5"
      remote_as: 65002
      description: "Peer to ISP2"
      password: "secret123"
</code></pre>
<h3>Multivendor Python Script</h3>
<p><strong>File:</strong> <code>generate_multivendor.py</code></p>
<pre><code class="language-python">from jinja2 import Environment, FileSystemLoader
import yaml
import os

VENDORS = {
    'nokia': 'nokia_bgp.j2',
    'cisco': 'cisco_bgp.j2',
    'juniper': 'juniper_bgp.j2'
}

def generate_multivendor_config(data_file, output_dir='multivendor_configs'):
    """Generate configs for all vendors from same data"""
    
    # Load data
    with open(data_file, 'r') as f:
        data = yaml.safe_load(f)
    
    # Create output directory
    if not os.path.exists(output_dir):
        os.makedirs(output_dir)
    
    # Set up Jinja2
    env = Environment(loader=FileSystemLoader('.'))
    
    # Generate for each vendor
    for vendor, template_file in VENDORS.items():
        template = env.get_template(template_file)
        config = template.render(**data)
        
        filename = f"{output_dir}/{vendor}_bgp_config.cfg"
        with open(filename, 'w') as f:
            f.write(config)
        
        print(f"✓ Generated {vendor.upper()} configuration: {filename}")

if __name__ == "__main__":
    generate_multivendor_config('bgp_data.yaml')
</code></pre>
<h3>Output:</h3>
<pre><code class="language-python">python3 generate_multivendor.py 

✓ Generated NOKIA configuration: multivendor_configs/nokia_bgp_config.cfg
✓ Generated CISCO configuration: multivendor_configs/cisco_bgp_config.cfg
✓ Generated JUNIPER configuration:   multivendor_configs/juniper_bgp_config.cfg
</code></pre>
<h1>Putting It All Together: Complete Workflow</h1>
<h2>Step 1: Create Templates</h2>
<p>Create template files for each configuration type</p>
<h2>Step 2: Prepare Data</h2>
<p>Create YAML files with customer/site/service data</p>
<h2>Step 3: Generate Configurations</h2>
<pre><code class="language-python">python3 generate_multivendor.py
</code></pre>
<h2>Step 4: Review Generated Configs</h2>
<h1>Key Takeaways</h1>
<ul>
<li><p><strong>Separation of Responsibilities -</strong> Templates (structure) vs Data (content)</p>
</li>
<li><p><strong>Reusability -</strong> One template, many configurations</p>
</li>
<li><p><strong>Consistency -</strong> Same structure every time</p>
</li>
<li><p><strong>Speed -</strong> Generate hundreds of configs in seconds</p>
</li>
<li><p><strong>Maintainability -</strong> Update template once, regenerate all configs</p>
</li>
</ul>
<h1>Download the Code</h1>
<p>All templates and scripts: <a href="https://gitlab.com/kgosileburu/network-automation-scripts">network-automation-week-3</a></p>
]]></content:encoded></item><item><title><![CDATA[Mastering Dynamic Configurations: A Beginner's Guide to Jinja2 - Part 1]]></title><description><![CDATA[In contemporary network automation, the ability to dynamically generate configurations is essential for network engineers operating in multi-vendor environments. Manual configuration is prone to error]]></description><link>https://codednetwork.com/mastering-dynamic-configurations-a-beginner-s-guide-to-jinja2-part-1</link><guid isPermaLink="true">https://codednetwork.com/mastering-dynamic-configurations-a-beginner-s-guide-to-jinja2-part-1</guid><category><![CDATA[Jinja2]]></category><category><![CDATA[Python 3]]></category><category><![CDATA[YAML]]></category><category><![CDATA[junos]]></category><category><![CDATA[Cisco]]></category><category><![CDATA[nokia]]></category><category><![CDATA[Devops]]></category><category><![CDATA[NetworkAutomation]]></category><dc:creator><![CDATA[Kgosi Leburu]]></dc:creator><pubDate>Tue, 24 Feb 2026 07:04:49 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1771379399135/7d90789d-8806-47a7-b7b9-8913f4df5648.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In contemporary network automation, the ability to dynamically generate configurations is essential for network engineers operating in multi-vendor environments. Manual configuration is prone to errors, lacks consistency, and is challenging to scale. Jinja2 templates serve as a powerful solution in this context. By integrating structured data (YAML/JSON) with reusable templates, network engineers can produce standardized and validated configurations across numerous devices in a safe and efficient manner.</p>
<p>This week, we are making significant progress by <strong>generating configurations dynamically</strong>. Rather than manually creating configuration files for each device, we will utilize <strong>Jinja2 templates</strong> to produce hundreds of configurations from a single data file.</p>
<p>In a <strong>real-world scenario</strong>, consider the task of configuring 100 new customer sites across Nokia SR OS, Cisco IOS XR, and Juniper routers. Each site requires unique VLANs, IP addresses, and routing configurations. Manually completing this task would take several days. However, using templates, it can be accomplished in just minutes.</p>
<h1>Why Choose Jinja2 for Network Automation?</h1>
<p><strong>Jinja2</strong> is a versatile and efficient templating engine commonly employed in automation frameworks like Ansible, as well as in custom Python automation scripts. It allows engineers to develop dynamic templates incorporating variables, loops, and conditional logic. This capability makes it particularly well-suited for generating configurations in multi-vendor environments.</p>
<h3>Think of it like this:</h3>
<ul>
<li><p>A mail merge in Microsoft Word</p>
</li>
<li><p>A form letter where you fill in the blanks</p>
</li>
<li><p>A recipe where you substitute ingredients</p>
</li>
</ul>
<h2>The Traditional Method (Manual Configuration)</h2>
<pre><code class="language-python"># Customer 1 - Nokia SROS router 
/configure service customer 100 create
description "Customer CodedNetwork Corp"
exit

# Customer 2 - Nokia SROS router 
/configure service customer 200 create
description "Lazyprogrammer INC"
exit

#...... Repeat for a 100 customers 😫
</code></pre>
<h2>The Modern Approach (Jinja2 Template)</h2>
<p><strong>Jinja2 Template:</strong></p>
<pre><code class="language-python"># Template only once !!! 🙂
/configure service customer {{ customer_id }} create
    description "{{ customer_name }}"
exit
</code></pre>
<p><strong>Data (YAML file):</strong></p>
<pre><code class="language-python">customers:
  - customer_id: 100
    customer_name: "Customer CodedNetwork Corp"
  - customer_id: 200
    customer_name: "Lazyprogrammer INC"
</code></pre>
<p><strong>Result</strong>: Generate 100 configurations in seconds.</p>
<h1>Understanding YAML</h1>
<p><strong>YAML (YAML Ain't Markup Language)</strong> is a human-readable data serialization format frequently utilized for configuration files and data exchange between languages with varying data structures. It is crafted to be straightforward to read and write, which makes it a favored option for configuration files in numerous applications, including network automation.</p>
<p>Key features of YAML include:</p>
<ol>
<li><p><strong>Simplicity</strong>: YAML is crafted to be straightforward to read and write, with a syntax that is more user-friendly compared to other data serialization formats like JSON or XML.</p>
</li>
<li><p><strong>Data Types</strong>: YAML supports a variety of data types, including scalars (strings, numbers, Booleans), lists, and dictionaries (also known as maps or hashes).</p>
</li>
<li><p><strong>Indentation</strong>: YAML uses indentation to denote the structure of data. The level of indentation indicates the hierarchy of data, similar to how Python uses indentation for code blocks.</p>
</li>
<li><p><strong>Comments</strong>: YAML permits comments, which can be added using the <code>#</code> symbol. This feature is beneficial for including explanations or notes within the configuration files.</p>
</li>
<li><p><strong>Compatibility</strong>: YAML is compatible with JSON, meaning that any valid JSON file is also a valid YAML file.</p>
</li>
</ol>
<p>In the context of <strong>Jinja2</strong> templates, YAML is frequently employed to store structured data that can be integrated into the templates to produce dynamic configurations. This approach facilitates a clear distinction between the data and the template logic, thereby simplifying the management and updating of configurations.</p>
<pre><code class="language-python"># Key-Value Pairs (Basic Structure)
hostname: core-r1
vendor: nokia
os: sros

# Lists( Arrays) 
interfaces:
  - name: ethernet-1/1
    ip: 10.0.0.1/24
  - name: ethernet-1/2
    ip: 10.0.1.1/24

# Nested Structures
device:
  hostname: core-r1
  vendor: nokia
  routing:
    bgp:
      asn: 65001
      router_id: 1.1.1.1

# Indentation (Critical Rule in YAML)
router:
  bgp:
    asn: 65001

# Strings, Integers, and Booleans
hostname: core-r1        # string
asn: 65001               # integer
enabled: true            # boolean

# Comments in YAML starts with "#"

hostname: core-r1
vendor: nokia  # SR OS platform
</code></pre>
<h2>Importance of YAML Validation</h2>
<p>A YAML validator is a tool or process that ensures a YAML file is syntactically correct, well-structured, and compliant before being utilized in automation pipelines. In network automation, where YAML is used for configuration generation, inventory, and deployments, validation serves as a <strong>critical safety layer</strong>.</p>
<h3>Prevents Automation Failures Prior to Deployment</h3>
<p>Automation tools such as Python scripts, Jinja2 templates, and Ansible heavily depend on YAML files. If the YAML is invalid, it can cause the entire pipeline to fail.</p>
<h3>Prevents Incorrect Configuration Generation (High Risk)</h3>
<p>When utilizing Jinja2 templates, YAML serves as the authoritative source. If the structure is incorrect, the templates may produce incomplete or erroneous configurations.</p>
<h3>Safeguards Production Networks Against Human Errors</h3>
<p>Most YAML errors result from human mistakes, including:</p>
<ul>
<li><p>Incorrect indentation</p>
</li>
<li><p>Duplicate keys</p>
</li>
<li><p>Missing quotes</p>
</li>
<li><p>Typographical errors</p>
</li>
</ul>
<p>In service provider environments, including core routers, software upgrades, and automation health checks, even a minor YAML error can lead to disruptions in:</p>
<ul>
<li><p>Routing policies</p>
</li>
<li><p>Interface provisioning</p>
</li>
<li><p>Telemetry pipelines</p>
</li>
</ul>
<p>A validator functions as a <strong>pre-change safety control</strong> before deployment</p>
<h3>Ensures Data Consistency Across Multi-Vendor Environments</h3>
<p>In multi-vendor automation environments (such as Nokia SR OS, Cisco IOS XR, and Junos), YAML is used to model device data. Validators ensure:</p>
<ul>
<li><p>Ensures correct schema structure</p>
</li>
<li><p>Verifies the presence of required fields (ASN, interfaces, hostname)</p>
</li>
<li><p>Confirms no missing attributes for specific vendors</p>
</li>
</ul>
<p>This is particularly crucial when creating vendor-specific configurations from a unified template.</p>
<h3>Supports CI/CD and Git-Based Automation Pipelines</h3>
<p>Modern network teams maintain YAML files in Git repositories to facilitate:</p>
<ul>
<li><p>Version control</p>
</li>
<li><p>Change tracking</p>
</li>
<li><p>Automated deployments</p>
</li>
</ul>
<p>Validation in CI/CD Pipelines:</p>
<pre><code class="language-bash">Git Commit → YAML Validation → Jinja2 Render → Lab Test → Production Deploy
</code></pre>
<p>If validation fails:</p>
<ul>
<li><p>Deployment is automatically halted</p>
</li>
<li><p>Prevents incorrect configurations from being applied to devices</p>
</li>
</ul>
<h3>Detects Indentation and Syntax Errors (A Common Issue)</h3>
<p>YAML is sensitive to indentation, requiring spaces only and not tabs.</p>
<p>A validator promptly identifies:</p>
<ul>
<li><p>Incorrect indentation</p>
</li>
<li><p>Invalid nesting</p>
</li>
<li><p>Structural inconsistencies</p>
</li>
</ul>
<h3>Enhances Automation Reliability and Scalability</h3>
<p>For large-scale automation involving hundreds of routers:</p>
<ul>
<li><p>Valid YAML ensures predictable automation behavior.</p>
</li>
<li><p>Invalid YAML leads to unpredictable failures.</p>
</li>
</ul>
<p>Benefits include:</p>
<ul>
<li><p>Stable template rendering</p>
</li>
<li><p>Faster troubleshooting</p>
</li>
<li><p>Cleaner automation logs</p>
</li>
<li><p>Reduced rollback scenarios</p>
</li>
</ul>
<h3>Essential for Jinja2 Template Rendering (Direct Impact)</h3>
<p>Jinja2 consumes YAML as input variables:</p>
<pre><code class="language-python">data = yaml.safe_load(open("devices.yaml"))
template.render(data)
</code></pre>
<h3>Security and Compliance Advantages</h3>
<p>Validated YAML helps prevent:</p>
<ul>
<li><p>Misconfigured access policies</p>
</li>
<li><p>Incorrect ACL deployments</p>
</li>
<li><p>Faulty automation scripts that push unsafe configurations</p>
</li>
</ul>
<p>My preferred YAML Validator is YAML Lint:</p>
<p><a href="https://www.yamllint.com/"><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771464081173/8ed75ea5-41ee-42b7-a636-bc217621e474.png" alt="" style="display:block;margin:0 auto" /></a></p>
<h2>Best Practice Workflow for Your Automation Stack</h2>
<pre><code class="language-python">        YAML Inventory File
                |
                v
        YAML Validator (yamllint)
                |
        +-------+--------+
        |                |
      Valid            Invalid
        |                |
        v                v
   Jinja2 Templates   Fix Errors
        |
        v
 Config Generation → Device Deployment → Health Checks
</code></pre>
<h1>Jinja2 Basics</h1>
<h2>1. Variables</h2>
<p>Variables in Jinja2 are represented by double curly braces <code>{{ }}</code>. These act as placeholders, which are substituted with actual data during the template rendering process.</p>
<pre><code class="language-python">hostname {{ hostname }}
interface {{ interface_name }}
 ip address {{ ip_address }}
</code></pre>
<h2>2. Loops</h2>
<p>Loops enable you to iterate over lists such as interfaces, VLANs, BGP neighbors, or services. This functionality is particularly beneficial when configuring multiple interfaces or services on a routers</p>
<pre><code class="language-python">{% for intf in interfaces %}
interface {{ intf.name }}
 description {{ intf.description }}
 ip address {{ intf.ip }}
{% endfor %}
</code></pre>
<h2>3. Conditionals</h2>
<p>Conditionals incorporate logic into templates, enabling configurations to adapt dynamically based on factors such as vendor, device role, or feature flags. This is essential in multi-vendor environments where syntax varies across platforms..</p>
<pre><code class="language-python">{% if vendor == "cisco" %}
router bgp {{ asn }}
{% elif vendor == "nokia" %}
configure router bgp {{ asn }}
{% elif vendor == "juniper" %}
set protocols bgp group external type external
{% endif %}
</code></pre>
<h2>4. Filters</h2>
<p>Filters in Jinja2 are used to transform or format data before rendering it into the final configuration. They help clean, modify, or standardize values automatically.</p>
<h3>Common Networking Use Cases:</h3>
<ul>
<li><p>Uppercase interface names</p>
</li>
<li><p>Formatting IP addresses</p>
</li>
<li><p>Default values for missing fields</p>
</li>
</ul>
<pre><code class="language-python">hostname {{ hostname | upper }}
interface {{ interface | default("GigabitEthernet0/0") }}
</code></pre>
<h1>Advanced Jinja2 Features</h1>
<h2>1. Macros (Reusable Blocks)</h2>
<pre><code class="language-python">{# Define a macro #}
{% macro interface_config(port, vlan, description) %}
interface {{ port }}
    description "{{ description }}"
    vlan {{ vlan }}
    no shutdown
{% endmacro %}

{# Use the macro #}
{% for intf in interfaces %}
{{ interface_config(intf.port, intf.vlan, intf.desc) }}
{% endfor %}
</code></pre>
<h2>2. Include Other Templates</h2>
<pre><code class="language-python">{# base_template.j2 #}
/configure
    {% include 'system_config.j2' %}
    {% include 'interface_config.j2' %}
    {% include 'routing_config.j2' %}
exit
</code></pre>
<h2>3. Custom Filters</h2>
<pre><code class="language-python">def ip_increment(ip, increment=1):
    """Increment IP address"""
    parts = ip.split('.')
    parts[3] = str(int(parts[3]) + increment)
    return '.'.join(parts)

# Add to Jinja2 environment
env.filters['ip_increment'] = ip_increment
</code></pre>
<h2>4. Usage in template</h2>
<pre><code class="language-python">neighbor {{ base_ip|ip_increment(1) }}
neighbor {{ base_ip|ip_increment(2) }}
</code></pre>
<h2>5. Conditional Includes</h2>
<pre><code class="language-python">{% if device_type == 'nokia' %}
    {% include 'nokia_specific.j2' %}
{% elif device_type == 'cisco' %}
    {% include 'cisco_specific.j2' %}
{% endif %}
</code></pre>
<h1>Detailed Internal Jinja2 Logic Flow</h1>
<h2>How Templates Process Data</h2>
<pre><code class="language-python">        Device Inventory (devices.yaml)
                   |
                   v
        +----------------------+
        | Load Data in Python  |
        | (dict / variables)   |
        +----------+-----------+
                   |
                   v
        +----------------------+
        | Pass Data to Jinja2  |
        |  template.render()   |
        +----------+-----------+
                   |
        +----------+-----------+
        |                      |
        v                      v
+-------------------+   +-------------------+
|   Variables       |   |   Conditionals    |
| {{ hostname }}    |   | if vendor == SR OS|
| {{ interfaces }}  |   | vendor logic      |
+-------------------+   +-------------------+
        |                      |
        +----------+-----------+
                   |
                   v
        +----------------------+
        |        Loops         |
        | for interface in list|
        +----------+-----------+
                   |
                   v
        +----------------------+
        |       Filters        |
        | upper, default, join |
        +----------+-----------+
                   |
                   v
        +----------------------+
        | Final Config Output  |
        | Ready for Deployment |
        +----------------------+
</code></pre>
<h1>Jinja2 + YAML Workflow (Advanced Automation Process)</h1>
<pre><code class="language-python">   devices.yaml (YAML Inventory)
                    |
                    v
            Python Script (Loader)
                    |
                    v
            Jinja2 Template Engine
                    |
                    v
        Rendered Router Configuration
                    |
                    v
        Deployment (SSH / NETCONF / API)
</code></pre>
<h1>What’s Next?</h1>
<p>In Part 1, we examined the principles of YAML and Jinja2 and their application in dynamic configuration generation. This enables network engineers to transition from static, manual CLI configurations to automated, data-driven deployments. This methodology ensures consistency, minimizes human error, and expedites deployment in production networks.</p>
<p>In Part 2 , we are going to show practical examples of dynamic configuration generation. We will go through a few use cases :</p>
<p>Use Case 1 : Nokia SROS service Configuration</p>
<p>Use Case 2 : Nokia SROS Interface Configuration</p>
<p>Use Case 3: Multivendor BGP Configuration ( Nokia SROS, Juniper, Cisco)</p>
<h1>Questions ?</h1>
<p>Reach out on LinkedIn!</p>
]]></content:encoded></item><item><title><![CDATA[How to Use API Networking to Retrieve Juniper vMX Device Information]]></title><description><![CDATA[Welcome back to our Network Automation series. Last week, we utilized SSH and Netmiko to back up configurations. This week, we will explore modern network APIs. Rather than delving into complex setups]]></description><link>https://codednetwork.com/how-to-use-api-networking-to-retrieve-juniper-vmx-device-information</link><guid isPermaLink="true">https://codednetwork.com/how-to-use-api-networking-to-retrieve-juniper-vmx-device-information</guid><category><![CDATA[junos]]></category><category><![CDATA[Juniper]]></category><category><![CDATA[api]]></category><category><![CDATA[network api]]></category><category><![CDATA[automation]]></category><category><![CDATA[netconf]]></category><category><![CDATA[inventory]]></category><category><![CDATA[xml]]></category><category><![CDATA[json]]></category><dc:creator><![CDATA[Kgosi Leburu]]></dc:creator><pubDate>Tue, 17 Feb 2026 07:10:21 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1770778358464/f6ed4842-91e6-496f-954b-7bbe3dea07db.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Welcome back to our Network Automation series. Last week, we utilized SSH and Netmiko to back up configurations. This week, we will explore <strong>modern network APIs</strong>. Rather than delving into complex setups, we will start with a fundamental task that every network engineer performs daily: <strong>gathering device information.</strong></p>
<p>By the conclusion of this post, you will be equipped to utilize network APIs to automatically gather:</p>
<ul>
<li><p>Device hostnames and software versions</p>
</li>
<li><p>Interface status</p>
</li>
<li><p>Device Uptime and Model</p>
</li>
</ul>
<p><strong>No configuration changes, no risk - just reading information the modern way.</strong></p>
<h1>Why Use APIs for Information Gathering?</h1>
<h2>The traditional method (SSH/CLI):</h2>
<pre><code class="language-bash">ssh admin@router1
show version | include Version
show interfaces terse
show system uptime
# Now manually copy-paste to Excel 
# Repeat for 50+ devices
</code></pre>
<h2>The API Approach:</h2>
<pre><code class="language-python"># Collect from 50+ devices in seconds
for device in devices:
  info =get_device_info(device)
# Automatically formatted in structured data
</code></pre>
<h2>Why is this a superior approach?</h2>
<ol>
<li><p><strong>Speed</strong>: Gather data from multiple devices simultaneously</p>
</li>
<li><p><strong>Structured Data</strong>: Utilize XML/JSON instead of parsing text</p>
</li>
<li><p><strong>Consistency</strong>: Ensure the same data format every time</p>
</li>
<li><p><strong>No Manual Copying</strong>: Directly export to Excel, database, or dashboard</p>
</li>
<li><p><strong>Automation Ready</strong>: Simple to schedule and monitor</p>
</li>
<li><p><strong>Error Reduction</strong>: Eliminate copy-paste mistakes</p>
</li>
</ol>
<h1>Understanding Network APIs</h1>
<h2>What is an API?</h2>
<p>An <strong>API (Application Programming Interface)</strong> serves as a means for programs to communicate with network devices. It is comparable to a menu at a restaurant:</p>
<ul>
<li><p>The menu displays available options <strong>(API documentation)</strong></p>
</li>
<li><p>You place an order <strong>(API call)</strong></p>
</li>
<li><p>You receive the requested item <strong>(API response)</strong></p>
</li>
</ul>
<h2>Two Primary Types for Network Devices:</h2>
<h3>NETCONF</h3>
<ul>
<li><p>Uses SSH connection (port 830)</p>
</li>
<li><p>Structured data (XML)</p>
</li>
<li><p>Supports transactions and validation</p>
</li>
<li><p>More powerful, model-driven</p>
</li>
</ul>
<details>
<summary>Note:</summary>
<p><em><mark class="bg-yellow-200 dark:bg-yellow-500/30">In this article we touch on NETCONF briefly just for common understanding. NETCONF will have a dedicated article with more in-depth details and more practical examples.</mark></em></p>
</details>

<h3>NETCONF Session Flow</h3>
<pre><code class="language-plaintext">Client                                  Network Device (Server)
|                                               |
| SSH Connection (port 830) |
| ==========================================&gt;   |
|                                               |
| &lt;hello&gt; (device capabilities) |
| &lt;-------------------------------------------  |
| &lt;hello&gt; (client capabilities) |
| -------------------------------------------&gt;  |
|                                               |
| &lt;rpc message-id="101"&gt;                        |
| &lt;lock&gt;                                        |
| &lt;target&gt;                                      |
| &lt;candidate/&gt;                                  |
| &lt;/target&gt;                                     |
| &lt;/lock&gt;                                       |
| &lt;/rpc&gt;                                        |
| -------------------------------------------&gt;  |
|                                               |
| &lt;rpc-reply message-id="101"&gt;                  |
| &lt;ok/&gt;                                         |
| &lt;/rpc-reply&gt;                                  |
| &lt;-------------------------------------------  |
|                                               |
</code></pre>
<h3>REST (Representational State Transfer) API</h3>
<ul>
<li><p>Uses HTTP/HTTPS</p>
</li>
<li><p>Usually JSON format</p>
</li>
<li><p>Simple request/response</p>
</li>
<li><p>Easy to understand</p>
</li>
</ul>
<h3>How REST Works</h3>
<pre><code class="language-plaintext">Client                            Server (Network Device)
|                                          |
| GET /api/v1/interfaces/eth0              |
| ----------------------------------------&gt;|
|                                          |
| HTTP/1.1 200 OK                          |
| Content-Type: application/json           |
| {                                        |
| "name": "eth0",                          |
| "status": "up",                          |
| "speed": "1000Mbps"                      |
| }                                        |
| &lt;----------------------------------------|
|                                          |
</code></pre>
<h3>REST Operations</h3>
<pre><code class="language-plaintext">HTTP Method   Purpose          Example
-----------   -------          -------
GET           Read resource    GET /api/interfaces
POST          Create resource  POST /api/interfaces
PUT           Update resource  PUT /api/interfaces/eth0
DELETE        Remove resource  DELETE /api/interfaces/eth1
PATCH         Partial update   PATCH /api/interfaces/eth0
</code></pre>
<h2>When to Use Each Method</h2>
<h3>Select REST when:</h3>
<ul>
<li><p>Utilize REST for straightforward and rapid integrations with network devices.</p>
</li>
<li><p>It is ideal for modern cloud-native applications.</p>
</li>
<li><p>Select REST if the vendor offers a well-documented REST API.</p>
</li>
<li><p>Opt for REST when seeking extensive support for various languages and tools.</p>
</li>
</ul>
<h3>Select NETCONF when:</h3>
<ul>
<li><p>You need transactional integrity for configuration changes</p>
</li>
<li><p>Working with critical infrastructure requiring validation</p>
</li>
<li><p>Managing complex, multi-step configurations</p>
</li>
<li><p>Rollback capabilities are essential Working with devices that support YANG data models</p>
</li>
<li><p>Enterprise network automation at scale</p>
</li>
</ul>
<h1>Prerequisites</h1>
<h2>Software Requirements</h2>
<pre><code class="language-python"># Install required Python libraries
pip install ncclient  # For NETCONF
pip install xmltodict # For parsing XML
pip install tabulate  # For pretty tables
pip install requests  # For REST APIs (if needed)
</code></pre>
<h2>Lab Setup</h2>
<p>For this tutorial, you will need access to:</p>
<ul>
<li>A Juniper vMX router with NETCONF enabled</li>
</ul>
<p><strong>Don't have a device?</strong> Use <a href="https://containerlab.dev/">containerlab</a></p>
<h2>Enable NETCONF on your device:</h2>
<h3>Juniper vMX</h3>
<pre><code class="language-python">set system services netconf ssh
set system services netconf rfc-compliant
commit
</code></pre>
<h3>Verify NETCONF Configuration and Status</h3>
<pre><code class="language-python">admin@juniper&gt; show configuration system services netconf    
ssh;
rfc-compliant;

admin@juniper&gt; show system connections | match 830           
tcp6       0      0  *.830        *.*                                           LISTEN
tcp4       0      0  *.830        *.*                                           LISTEN
</code></pre>
<h1>Juniper vMX - System Information Accessed via NETCONF</h1>
<p>In this example, we are gathering basic information from a Juniper vMX router.</p>
<h2>Understanding the Code Structure</h2>
<pre><code class="language-python">from ncclient import manager
import xmltodict
from tabulate import tabulate
import getpass
import csv
from datetime import datetime
import os
</code></pre>
<h3>Explanation of Each Import</h3>
<ul>
<li><p><code>from ncclient import manager</code>: This import allows us to manage NETCONF sessions with network devices.</p>
</li>
<li><p><code>import xmltodict</code>: This library is used to convert XML data into a Python dictionary, making it easier to work with.</p>
</li>
<li><p><code>from tabulate import tabulate</code>: This module helps in creating well-formatted tables for displaying data.</p>
</li>
<li><p><code>import getpass</code>: This module is used to securely prompt for a password without displaying it on the screen.</p>
</li>
<li><p><code>import csv</code>: This library provides functionality to read from and write to CSV files.</p>
</li>
<li><p><code>from datetime import datetime</code>: This import allows us to work with dates and times in our code.</p>
</li>
<li><p><code>import os</code>: This module provides a way to interact with the operating system, such as handling file paths.</p>
</li>
</ul>
<h3>Device Connection Details</h3>
<pre><code class="language-python"># Device details
VMX_HOST = "172.20.20.16"
VMX_PORT = 830
VMX_USER = "admin"
VMX_PASS = getpass.getpass(f"Password for {VMX_USER}@{VMX_HOST}: ")
</code></pre>
<h3>Why use port 830?</h3>
<ul>
<li><p>Port 830 is the standard port for NETCONF over SSH.</p>
</li>
<li><p>Port 22 is used for regular SSH connections.</p>
</li>
<li><p>Port 830 provides us with NETCONF capabilities.</p>
</li>
</ul>
<h3>Why use <code>getpass</code>?</h3>
<pre><code class="language-python"># Bad - password visible in code and on screen
VMX_PASS = "coded123"
# Good - password not shown when typing
VMX_PASS = getpass.getpass("Password: ")
</code></pre>
<h3>The Connection Function</h3>
<pre><code class="language-python">def connect_vmx():
    """Connect to Juniper vMX via NETCONF"""
    return manager.connect(
        host=VMX_HOST,
        port=VMX_PORT,
        username=VMX_USER,
        password=VMX_PASS,
        device_params={'name': 'junos'},
        hostkey_verify=False,
        look_for_keys=False,
        allow_agent=False
    )
</code></pre>
<h3>Breaking Down the Parameters:</h3>
<ul>
<li><p><code>host=VMX_HOST</code> = The IP address for connection</p>
</li>
<li><p><code>port=VMX_PORT</code> = Port 830 (used for NETCONF)</p>
</li>
<li><p><code>username=VMX_USER</code> = The login username</p>
</li>
<li><p><code>password=VMX_PASS</code> = The login password</p>
</li>
<li><p><code>device_params={'name': 'junos'}</code> = Informs ncclient that this is a Juniper device</p>
</li>
<li><p><code>hostkey_verify=False</code> = Disables SSH key verification (suitable for lab environments, not recommended for production)</p>
</li>
<li><p><code>look_for_keys=False</code> = Disables searching for SSH key files</p>
</li>
<li><p><code>allow_agent=False</code> = Disables the use of the SSH agent</p>
</li>
</ul>
<h3>What <code>manager.connect()</code> returns:</h3>
<ul>
<li><p>A connection object that allows us to send commands</p>
</li>
<li><p>Automatically manages the SSH connection</p>
</li>
<li><p>Oversees the NETCONF session</p>
</li>
</ul>
<h3>Retrieving System Information</h3>
<pre><code class="language-python">def get_vmx_system_info():
    """Get system information from vMX"""
    
    with connect_vmx() as m:
        # Get system information
        result = m.command(command='show version', format='xml')
        data = xmltodict.parse(result.tostring)
        
        # Get uptime
        uptime_result = m.command(command='show system uptime', format='xml')
        uptime_data = xmltodict.parse(uptime_result.tostring)
        
        return data, uptime_data
</code></pre>
<h3>What is <code>with ... as m</code>?</h3>
<pre><code class="language-python">with connect_vmx() as m:
# Use connection 'm' here
# Connection automatically closes when done
</code></pre>
<p>This is a <strong>context manager</strong></p>
<p>Benefits:</p>
<ul>
<li><p>Automatically closes connection even if there's an error</p>
</li>
<li><p>Prevents connection leaks</p>
</li>
<li><p>Cleaner than manual connect/disconnect</p>
</li>
</ul>
<h3>The <code>m.command()</code> method:</h3>
<pre><code class="language-python">result = m.command(command='show version', format='xml')
</code></pre>
<h3>Breaking it down:</h3>
<ul>
<li><p><code>m.command()</code> = Executes a Junos operational command</p>
</li>
<li><p><code>command='show version'</code> = The specific CLI command to be executed</p>
</li>
<li><p><code>format='xml'</code> = Retrieves the response in XML format (structured data)</p>
</li>
</ul>
<h3>Why use XML instead of text?</h3>
<p>Text output:</p>
<pre><code class="language-plaintext">Hostname: juniper
Model: vmx
Junos: 22.4R1.10
</code></pre>
<p>XML output:</p>
<pre><code class="language-xml">&lt;software-information&gt;
  &lt;host-name&gt;juniper&lt;/host-name&gt;
  &lt;product-model&gt;vmx&lt;/product-model&gt;
  &lt;junos-version&gt;22.4R1.10&lt;/junos-version&gt;
&lt;/software-information&gt;
</code></pre>
<p>XML is:</p>
<ul>
<li><p>Structured (easy to parse)</p>
</li>
<li><p>Consistent format</p>
</li>
<li><p>Machine-readable</p>
</li>
<li><p>Contains all information</p>
</li>
</ul>
<h3>Converting XML to Dictionary:</h3>
<pre><code class="language-python">uptime_data = xmltodict.parse(uptime_result.tostring)
</code></pre>
<p>This process converts XML data into a Python dictionary:</p>
<pre><code class="language-python">data ={
   'rpc-reply':{
      'software-information':{
        'host-name':'juniper',
        'product-model':'vmx',
        'junos-version':'22.4R1.110'
       }
    }
}
</code></pre>
<h3>Extracting and formatting the data</h3>
<pre><code class="language-python">def parse_system_data(sys_data, uptime_data):
    """Parse and extract system information"""
    
    try:
        software = sys_data['rpc-reply']['software-information']
        uptime_info = uptime_data['rpc-reply']['system-uptime-information']
        
        info = {
            'hostname': software.get('host-name', 'N/A'),
            'model': software.get('product-model', 'N/A'),
            'version': software.get('junos-version', 'N/A'),
            'uptime': uptime_info.get('system-booted-time', {}).get('time-length', 'N/A')
        }
        
        return info
        
    except Exception as e:
        print(f"Could not parse system info: {e}")
        return {
            'hostname': 'N/A',
            'model': 'N/A',
            'version': 'N/A',
            'uptime': 'N/A'
        }
</code></pre>
<h3>Why <code>try/except</code> ?</h3>
<ul>
<li><p>The XML structure may vary</p>
</li>
<li><p>Missing fields will not cause the script to crash</p>
</li>
<li><p>Ensures graceful error handling</p>
</li>
</ul>
<h3>What is <code>.get('key', 'N/A')</code> ?</h3>
<p>Safe dictionary access:</p>
<pre><code class="language-python"># If key exists, return value
# If key doesn't exist, return 'N/A' instead of crashing
value=dictionary.get('key','N/A')
</code></pre>
<h3>Displaying the information</h3>
<pre><code class="language-python">def display_system_info(system_info):
    """Display system information in formatted output"""
    
    print("="*60)
    print("Juniper vMX Device Information")
    print("="*60)
</code></pre>
<h3>Creating the headers</h3>
<pre><code class="language-python">print("="*60)
# Prints 60 equal signs
</code></pre>
<h3>Exporting to CSV</h3>
<p>Let's save the collected data to a CSV file for use in Excel:</p>
<pre><code class="language-python">def export_system_to_csv(system_info, filename=None):
    """Export system information to CSV file"""
    
    if filename is None:
        timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
        filename = f"vmx_system_info_{timestamp}.csv"
    
    # Create exports directory if it doesn't exist
    export_dir = 'exports'
    if not os.path.exists(export_dir):
        os.makedirs(export_dir)
    
    filepath = os.path.join(export_dir, filename)
    
    with open(filepath, 'w', newline='') as f:
        writer = csv.writer(f)
        
        # Write header
        writer.writerow([
            'Collection Time',
            'Device IP',
            'Hostname',
            'Model',
            'Junos Version',
            'Uptime'
        ])
        
        # Write data
        writer.writerow([
            datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
            VMX_HOST,
            system_info['hostname'],
            system_info['model'],
            system_info['version'],
            system_info['uptime']
        ])
    
    print(f"\n✓ System information exported to: {filepath}")
    return filepath
</code></pre>
<h2>Complete Output</h2>
<pre><code class="language-python"># Exexute the script 
python3 junos_get_csv.py

============================================================
vMX Device Information Collector
============================================================
Target: 172.20.20.16
Time: 2026-02-11 13:14:13
============================================================

Collecting system information...
✓ System information collected
Collecting interface information...
✓ Interface information collected (10 interfaces)

============================================================
Juniper vMX Device Information
============================================================
Hostname            : juniper
Model               : vmx
Junos Version       : 22.4R1.10
Uptime              : {'@seconds': '2431474', '#text': '4w0d 03:24'}

============================================================
Interface Status
============================================================
+-------------+----------------+---------------+
| Interface   | Admin Status   | Oper Status   |
+=============+================+===============+
| ge-0/0/0    | up             | up            |
+-------------+----------------+---------------+
| gr-0/0/0    | up             | up            |
+-------------+----------------+---------------+
| ip-0/0/0    | up             | up            |
+-------------+----------------+---------------+
| lc-0/0/0    | up             | up            |
+-------------+----------------+---------------+
| lt-0/0/0    | up             | up            |
+-------------+----------------+---------------+
| mt-0/0/0    | up             | up            |
+-------------+----------------+---------------+
| pd-0/0/0    | up             | up            |
+-------------+----------------+---------------+
| pe-0/0/0    | up             | up            |
+-------------+----------------+---------------+
| pfe-0/0/0   | up             | up            |
+-------------+----------------+---------------+
| pfh-0/0/0   | up             | up            |
+-------------+----------------+---------------+

============================================================
Exporting Data
============================================================

✓ System information exported to: exports/vmx_system_info_20260211_131415.csv
✓ Interface information exported to: exports/vmx_interfaces_20260211_131415.csv

============================================================
Collection Summary
============================================================
✓ Device: juniper (172.20.20.16)
✓ Model: vmx
✓ Version: 22.4R1.10
✓ Interfaces Collected: 10
✓ System CSV: exports/vmx_system_info_20260211_131415.csv
✓ Interface CSV: exports/vmx_interfaces_20260211_131415.csv
============================================================
</code></pre>
<h1>Troubleshooting Common Issues</h1>
<h2>Issue 1: Connection Timeout</h2>
<h3>Error:</h3>
<pre><code class="language-python">TimeoutError: timed out
</code></pre>
<h3>Solutions:</h3>
<pre><code class="language-python"># Increase timeout
m = manager.connect(
  host=device['host'],
  timeout=30, # Increase from default 10 seconds
  ...
)
</code></pre>
<h2>Issue 2: Authentication Failed</h2>
<h3>Error:</h3>
<pre><code class="language-python">AuthenticationException: Authentication failed
</code></pre>
<h3>Solutions:</h3>
<ol>
<li><p>Verify username/password</p>
</li>
<li><p>Check user has correct privileges</p>
</li>
<li><p>Ensure NETCONF is enabled</p>
</li>
</ol>
<h2>Issue 3: XML Parsing Errors</h2>
<h3>Error:</h3>
<pre><code class="language-python">KeyError: 'software-information'
</code></pre>
<h3>Solutions:</h3>
<pre><code class="language-python"># Use .get() with defaults
software=data.get('rpc-reply',{}).get('software-information',{})
hostname=software.get('host-name','Unknown')
</code></pre>
<h2>Issue 4: NETCONF Not Enabled</h2>
<h3>Error:</h3>
<pre><code class="language-python">Connection refused on port 830
</code></pre>
<h3>Solutions:</h3>
<pre><code class="language-python">show system connections | match 830 
</code></pre>
<h1>Key Differences: NETCONF vs. SSH/CLI</h1>
<table style="min-width:75px"><colgroup><col style="min-width:25px"></col><col style="min-width:25px"></col><col style="min-width:25px"></col></colgroup><tbody><tr><th><p><strong>Feature</strong></p></th><th><p><strong>NETCONF</strong></p></th><th><p><strong>CLI</strong></p></th></tr><tr><td><p><strong>Data Format</strong></p></td><td><p>XML (structured)</p></td><td><p>Text (unstructured)</p></td></tr><tr><td><p><strong>Parsing</strong></p></td><td><p>Easy (XML parsing)</p></td><td><p>Hard ( Regex , text parsing)</p></td></tr><tr><td><p><strong>Consistency</strong></p></td><td><p>Always same format</p></td><td><p>Can change between versions</p></td></tr><tr><td><p><strong>Speed</strong></p></td><td><p>Fast</p></td><td><p>Slower</p></td></tr><tr><td><p><strong>Validation</strong></p></td><td><p>Built in</p></td><td><p>None</p></td></tr><tr><td><p><strong>Transactional Configurations</strong></p></td><td><p>Supported</p></td><td><p>Not supported</p></td></tr></tbody></table>

<h1>Next Steps</h1>
<p>Now that you can collect device information, consider the following actions:</p>
<ol>
<li><p><strong>Schedule Regular Collection</strong> - Utilize cron or Task Scheduler</p>
</li>
<li><p><strong>Develop a Dashboard</strong> - Present data in real-time</p>
</li>
<li><p><strong>Track Changes</strong> - Compare today's data with yesterday's data.</p>
</li>
<li><p><strong>Alert on Differences</strong> - Send an email notification when versions change.</p>
</li>
<li><p><strong>Export to Database</strong> - Store historical data</p>
</li>
</ol>
<h1>Key Takeaways</h1>
<p>✅ <strong>NETCONF is powerful</strong> - It offers structured data, supports transactions, and provides validation.</p>
<p>✅ <strong>XML is consistent</strong> - It maintains the same format consistently.</p>
<p>✅ <strong>Python simplifies the process</strong> - ncclient manages the complexity</p>
<p>✅ <strong>Start with read-only</strong> - This approach eliminates the risk of causing disruptions.</p>
<p>✅ <strong>Build confidence</strong> - Gain expertise in querying before proceeding to configuration.</p>
<h1>Conclusion</h1>
<p>Congratulations on mastering the use of NETCONF to gather device information from Juniper vMX routers :</p>
<ul>
<li><p>Establish a connection to the device using NETCONF</p>
</li>
<li><p>Execute operational commands</p>
</li>
<li><p>Parse XML responses</p>
</li>
<li><p>Extract and format the data</p>
</li>
<li><p>Export data to CSV for analysis</p>
</li>
</ul>
<p>Next week, we will utilize Jinja2 templates to dynamically generate configurations across multiple vendors.</p>
<h1>Download the code</h1>
<p>All the code from this post is available on GitLab:<a href="https://gitlab.com/kgosileburu/network-automation-scripts">network-automation-week-2</a></p>
<h1><strong>Questions or Feedback?</strong></h1>
<p>Connect with me on LinkedIn</p>
]]></content:encoded></item><item><title><![CDATA[Introduction to Network Automation: Build Your First Backup Script with Python for a multi-vendor environment - Part 2]]></title><description><![CDATA[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.
I]]></description><link>https://codednetwork.com/introduction-to-network-automation-build-your-first-backup-script-with-python-for-a-multi-vendor-environment-part-2</link><guid isPermaLink="true">https://codednetwork.com/introduction-to-network-automation-build-your-first-backup-script-with-python-for-a-multi-vendor-environment-part-2</guid><category><![CDATA[Juniper]]></category><category><![CDATA[Cisco]]></category><category><![CDATA[nokia]]></category><category><![CDATA[Python]]></category><category><![CDATA[Network Automation]]></category><dc:creator><![CDATA[Kgosi Leburu]]></dc:creator><pubDate>Tue, 10 Feb 2026 08:23:02 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1768966872820/88573762-8193-41be-8c15-6b40ae5392c0.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><strong>Welcome back to Part 2 of our network automation series.</strong> In Part 1, we explored the fundamentals of Netmiko, including connecting to a device and executing commands. Now, we will apply those skills.</p>
<p>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.</p>
<p><strong>What you'll learn:</strong></p>
<ul>
<li><p>Efficiently managing multiple device connections</p>
</li>
<li><p>Implementing error handling for practical scenarios</p>
</li>
<li><p>Organizing and timestamping backup files</p>
</li>
</ul>
<p><strong>What you'll need:</strong></p>
<ul>
<li><p>A basic understanding of Netmiko (as covered in Part 1)</p>
</li>
<li><p>Python 3.x installed</p>
</li>
<li><p>Access to network devices (whether physical, virtual, or via Containerlab)</p>
</li>
<li><p>A text editor or IDE</p>
</li>
</ul>
<p>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</p>
<h1>Building Your First Backup Script</h1>
<h2>Version 2: Multi-Vendor Support</h2>
<p>Now, let's extend this to support Nokia, Cisco, and Juniper devices.</p>
<pre><code class="language-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'[&gt;#]'
    }
}

# 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) &lt; 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('#&gt; ')
#        print(f"✓ Device hostname: {hostname}")

        # Get hostname for filename
        raw_hostname = connection.find_prompt().strip('#&gt; ')

        # 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()
</code></pre>
<h2>Expected Output</h2>
<pre><code class="language-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
======================================================================
</code></pre>
<h2>Lets analyze the script for a clearer understanding</h2>
<ol>
<li><p><code>from netmiko import ConnectHandler</code> Firstly we import the <code>ConnectHandler</code> from the <code>netmiko</code> library used to connect into network devices</p>
</li>
<li><p><code>from datetime import datetime</code> We then import <code>datetime</code> from the <code>datetime</code> library , to be able to work with time and dates</p>
</li>
<li><p><code>import os</code> which is an operating system library used for working with files and folders</p>
</li>
<li><p><code>import sys</code> used for system-level operations</p>
</li>
<li><p><strong>Device Inventory</strong>: This is where we define all the devices we want to back up. The <code>devices</code> variable holds our list of all devices. Inside the list, each device is represented by a dictionary</p>
</li>
<li><p><code>vendor_commands</code> A dictionary of dictionaries (nested dictionaries). Each vendor has its own set of commands</p>
</li>
<li><p>Create a backup directory if it doesn’t exist. <code>backup_dir = 'backups'</code> a variable is created named <code>backup_dir</code> . This is the name of the folder where we will store the backups. <code>os.path.exists(backup_dir)</code></p>
<p>Checks if the folder exists and it returns <code>True</code> if it exists, <code>False</code> if it doesn't</p>
</li>
<li><p>The <strong>Validation Function</strong> <code>def validate_config(config_text, vendor)</code> checks if <code>config_text</code> is empty or None and returns True if it is empty, and False if it has content. The <code>or</code> operator means if either condition is true, the entire expression is true. <code>len(config_text) &lt; 100</code> Checks if config has fewer than 100 characters. <code>return</code> returns two values to the function <code>false</code> = validation failed and <code>"Configuration appears..."</code> = Error Message .</p>
</li>
<li><p><strong>Vendor-Specific Validation</strong> 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..</p>
</li>
<li><p><code>def backup_device(device_info)</code>: This function takes one input, <code>device_info</code>, which is a dictionary containing the device details. <code>vendor = device_info.get('vendor', 'unknown')</code> uses <code>.get()</code> to safely retrieve the vendor. If the 'vendor' key doesn't exist, it uses 'unknown' as the default. <code>host = device_info['host']</code> gets the IP address from <code>device_info</code>.</p>
</li>
<li><p>Create connection parameters using <code>connection_params = {k: v for k, v in device_info.items() if k != 'vendor'}</code>. The <code>device_info.items()</code> retrieves pairs of (key, value), and <code>for k, v in ...</code> loops through each pair. If <code>k != 'vendor'</code>, it doesn't include the vendor, and <code>{k: v ...}</code> creates a new dictionary. Connect to the device.</p>
</li>
<li><p>Retrieve the <strong>hostname</strong> for the <strong>filename</strong> and sanitize it by removing any characters that are not valid for filenames.</p>
</li>
<li><p><code>connection.send_command(commands['pager'], expect_string=commands['prompt'])</code> used to turn off pagination</p>
</li>
<li><p>The <code>commands['config']</code> is used to retrieve the configuration, and <code>expect_string=commands['prompt']</code> specifies the prompt to wait for before continuing.</p>
</li>
<li><p><code>is_valid, message = validate_config(config, vendor)</code> calls the validation function. If the validation fails, it prints a warning.</p>
</li>
<li><p>Create a timestamp and a vendor directory. Use <code>os.path.join()</code> to combine folder paths.</p>
</li>
<li><p>Create the filename using <code>filename = f"{vendor_dir}/{hostname}_{timestamp}.txt"</code> and save the configuration.</p>
</li>
<li><p>Verify file creation by checking the file size in bytes with <code>os.path.getsize(filename)</code>.</p>
</li>
<li><p>Error Handling except ConnectionError as e : Catches only connection-related errors and except Exception as e : Catches any other error</p>
</li>
<li><p>The main function <code>main()</code> orchestrates the entire backup process. It calls <code>backup_device()</code> for each device and collects and displays the results.</p>
</li>
</ol>
<h2>How It All Works Together</h2>
<h3>The Complete Flow</h3>
<pre><code class="language-plaintext">1. Script starts
├─&gt; Import libraries
├─&gt; Define device list
├─&gt; Define vendor commands
└─&gt; Create backup directory

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

3. Print summary
├─&gt; Show success count
├─&gt; Show failure count
├─&gt; List successful backups
├─&gt; List failed backups
└─&gt; Exit with status code
</code></pre>
<h1><strong>Best Practices and Tips</strong></h1>
<h2>Security Considerations</h2>
<p><strong>Avoid hardcoding passwords in scripts</strong>. Consider using one of the following approaches:</p>
<ul>
<li><p>Environmental variables</p>
</li>
<li><p>Encrypted vaults (Ansible Vault, HashiCorp Vault)</p>
</li>
<li><p>Keyring libraries</p>
</li>
<li><p>Prompts for passwords at runtime</p>
</li>
</ul>
<p>Example with environment variables:</p>
<pre><code class="language-python">devices = 
    {
        'device_type': 'alcatel_sros',
        'host': '172.20.20.13',
        'username': os.environ.get('NETWORK_USERNAME'),
        'password': os.environ.get('NETWORK_PASSWORD'),
  
    }
</code></pre>
<h2>Error Handling</h2>
<p>Always implement proper error handling:</p>
<ul>
<li><p>Connection timeouts</p>
</li>
<li><p>Authentication failures</p>
</li>
<li><p>Device unreachable</p>
</li>
<li><p>Insufficient privileges</p>
</li>
</ul>
<h2>Logging</h2>
<p>Comprehensive logging helps troubleshoot issues:</p>
<ul>
<li><p>Connection attempts</p>
</li>
<li><p>Successful operations</p>
</li>
<li><p>Errors with full stack traces</p>
</li>
<li><p>Execution duration</p>
</li>
</ul>
<h2>Backup Retention</h2>
<p>Implement a retention policy to manage disk space</p>
<h1>Troubleshooting Common Issues</h1>
<h2>Issue 1: "Authentication failed"</h2>
<p>Solution : Verify credentials, check if AAA is configured correctly</p>
<h2>Issue 2: "Connection timeout"</h2>
<p>Solution : Verify network connectivity, check firewall rules, ensure SSH is enabled</p>
<h2>Issue 3: "Command not recognized"</h2>
<p>Solution : Verify the backup command for your device type, some platforms use different commands</p>
<h2>Issue 4: "Permission denied"</h2>
<p>Solution : Ensure the user has proper privilege levels (Cisco: privilege 15, Juniper: superuser class)</p>
<h1>Next Steps</h1>
<p>Now that you have a functional backup script, consider implementing the following enhancements:</p>
<ol>
<li><p><strong>Schedule automated backups</strong> using cron (Linux) or Task Scheduler (Windows)</p>
</li>
<li><p><strong>Add Git integration</strong> to track configuration changes over time</p>
</li>
<li><p><strong>Implement differential backups</strong> to store only changes</p>
</li>
<li><p><strong>Add email notifications</strong> for backup failures</p>
</li>
<li><p><strong>Extend to more device types</strong> (Palo Alto, Fortinet etc.)</p>
</li>
</ol>
<h1>Conclusion</h1>
<p>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.</p>
<p>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.</p>
<h1>Download the code</h1>
<p>All the code from this post is available on GitLab. : <a href="https://gitlab.com/kgosileburu/network-automation-scripts">network-automation-week-1-part2</a></p>
<h1>Questions or Feedback?</h1>
<p>Connect with me on LinkedIn. I'd love to hear about your automation journey!</p>
]]></content:encoded></item><item><title><![CDATA[Introduction to Network Automation: Build Your First Backup Script with Python for a multi-vendor environment - Part 1]]></title><description><![CDATA[Welcome to the first post in our Network Automation series! If you're a network engineer who's been manually logging into devices to grab configurations, this post is for you. Today, we're diving into]]></description><link>https://codednetwork.com/introduction-to-network-automation-build-your-first-backup-script-with-python-for-a-multi-vendor-environment-part-1</link><guid isPermaLink="true">https://codednetwork.com/introduction-to-network-automation-build-your-first-backup-script-with-python-for-a-multi-vendor-environment-part-1</guid><category><![CDATA[NetworkAutomation]]></category><category><![CDATA[Cisco]]></category><category><![CDATA[Juniper]]></category><category><![CDATA[nokia]]></category><category><![CDATA[Devops]]></category><category><![CDATA[Netmiko]]></category><category><![CDATA[junos]]></category><category><![CDATA[Python 3]]></category><dc:creator><![CDATA[Kgosi Leburu]]></dc:creator><pubDate>Tue, 03 Feb 2026 07:34:05 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1768887748566/2427f080-f50d-4cf3-b539-402b6a3fafe1.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Welcome to the first post in our Network Automation series! If you're a network engineer who's been manually logging into devices to grab configurations, this post is for you. Today, we're diving into practical network automation by building a Python script that automatically backs up configurations from Nokia SROS, Cisco IOS and Juniper Junos devices.</p>
<p>By the end of this post, you'll have a working script that can save you hours of repetitive work and provide a solid foundation for your automation journey.</p>
<h1>Why Network Automation?</h1>
<p>Before we jump into code, let's address the elephant in the room: why automate?</p>
<ul>
<li><p><strong>Time Savings</strong>: Manually backing up 50 devices is time-consuming, taking hours, whereas automation completes the task in minutes.</p>
</li>
<li><p><strong>Consistency</strong>: Scripts execute tasks without missing steps or making typographical errors.</p>
</li>
<li><p><strong>Audit Trail</strong>: Automated backups with timestamps provide a dependable change history.</p>
</li>
<li><p><strong>Disaster Recovery</strong>: Regular automated backups ensure preparedness for unforeseen events.</p>
</li>
<li><p><strong>Scalability</strong>: As your network expands, automation prevents manual processes from becoming a bottleneck.</p>
</li>
</ul>
<h1>Why Coding is Essential for Network Engineers Today?</h1>
<p>Modern networks are no longer configured on a device-by-device basis. They are <strong>software-driven systems</strong>. Acquiring coding skills enables network engineers to transition from <em>manual operators to automation engineers.</em></p>
<p>Here’s what coding enables:</p>
<ol>
<li><p><strong>Automation Over Repetition</strong></p>
<p>Instead of logging into 100 devices:</p>
<ul>
<li><p>A script can push configs</p>
</li>
<li><p>Validate state</p>
</li>
<li><p>Roll back safely</p>
</li>
</ul>
</li>
<li><p><strong>Expedited Troubleshooting and Enhanced Visibility</strong></p>
<p>With code, you can:</p>
<ul>
<li><p>Pull live data from devices</p>
</li>
<li><p>Parse outputs (JSON/XML/YANG)</p>
</li>
<li><p>Detect anomalies automatically</p>
</li>
</ul>
</li>
<li><p><strong>Vendor-Agnostic Networking</strong></p>
<p>Coding helps you think in <strong>models</strong>, not commands:</p>
<ul>
<li><p>YANG models</p>
</li>
<li><p>OpenConfig</p>
</li>
<li><p>REST / NETCONF / gNMI</p>
</li>
</ul>
</li>
<li><p><strong>Career Longevity and Growth</strong></p>
<p>Roles that increasingly expect coding skills:</p>
<ul>
<li><p>Network Automation Engineer</p>
</li>
<li><p>NetDevOps Engineer</p>
</li>
<li><p>SRE (Network-focused)</p>
</li>
<li><p>Cloud Network Engineer</p>
</li>
</ul>
</li>
</ol>
<blockquote>
<p>"You're either the one who creates the automation, or you're the one being automated." ~ Tom Preston-Werner, co-founder and former CEO of GitHub</p>
</blockquote>
<h1>Prerequisites</h1>
<p>Before we begin, ensure you have:</p>
<ul>
<li><p>Python 3.8 or higher installed</p>
</li>
<li><p>Basic understanding of Python (variables, functions, loops)</p>
</li>
<li><p>Access to network devices (lab or production)</p>
</li>
<li><p>SSH enabled on your network devices</p>
</li>
</ul>
<h1>Setting Up Your Environment</h1>
<h2>Step 1: Create a Virtual Environment</h2>
<p>Virtual environments keep your project dependencies isolated and clean.</p>
<pre><code class="language-bash"># Create a new directory for your project
mkdir network-automation
cd network-automation
# Create a virtual environment
python3 -m venv venv
# Activate the virtual environment
# On Linux/Mac:
source venv/bin/activate
# On Windows:
venv\Scripts\activate
</code></pre>
<h2>Step 2: Install the Required Libraries</h2>
<p>We'll use Netmiko , a popular Python library that simplifies SSH connections to network devices.</p>
<pre><code class="language-python">pip3 install netmiko
</code></pre>
<h2>Step 3: Set Up a Lab Environment for Testing</h2>
<p>In my testing environment, I am utilizing <strong>Containerlab</strong>. <strong>Containerlab</strong> is an open-source tool designed for creating network topologies with containerized network devices. It allows you to quickly set up multi-vendor network labs on your laptop without the need for hypervisors or heavy virtual machines, using only lightweight containers.</p>
<h3>Multi-Vendor Network Topology:</h3>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768958379269/c2c464cd-e932-44c8-9acc-5f0b4091eefb.png" alt="" style="display:block;margin-left:auto" />

<h1>Understanding Netmiko</h1>
<p><strong>Netmiko</strong> is a <strong>Python library</strong> that simplifies SSH connections to network devices, making it easier for network engineers to automate tasks without needing deep software engineering skills</p>
<p>Think of Netmiko as:</p>
<blockquote>
<p>“SSH for network engineers, done the right way.”</p>
</blockquote>
<h2>What issues does Netmiko address?</h2>
<p>When you manually SSH into a router, you must manage prompts, paging, privilege modes, and command timing on your own. Netmiko automates these processes. It is designed to interact seamlessly with devices from Nokia, Cisco, Juniper, Arista, HP, and many other vendors, eliminating the need to write custom code for each one.</p>
<h2>Key features</h2>
<ul>
<li><p><strong>Automatic handling of prompts</strong>: Knows when commands finish executing</p>
</li>
<li><p><strong>Paging disabled</strong>: Automatically handles "-- More --" prompts</p>
</li>
<li><p><strong>Multiple vendors</strong>: Just change <code>device_type</code> to support different platforms</p>
</li>
<li><p><strong>Configuration mode</strong>: Built-in methods for entering config mode and sending commands</p>
</li>
<li><p><strong>Error handling</strong>: Can detect command failures</p>
</li>
</ul>
<h1>Building Your First Backup Script</h1>
<h2>Version 1: Single Device Backup</h2>
<p>Let's begin with a simple script to back up a single Nokia SROS device.</p>
<pre><code class="language-python">from netmiko import ConnectHandler
from datetime import datetime
import os
import sys

# Device details
nokia_device = {
    'device_type': 'alcatel_sros',
    'host': '172.20.20.13',
    'username': 'admin',
    'password': 'admin',
    'timeout': 60,  # Increased timeout for large configs
    'session_log': 'netmiko_session.log'  # Optional: log session for debugging
}

# Commands to execute
commands = [
    'environment no more',
    'admin display-config'
]

# 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}")

def validate_config(config_text):
    """Validate that configuration was retrieved successfully"""
    if not config_text or len(config_text) &lt; 100:
        return False, "Configuration appears empty or too short"
    
    # Check for common SR OS config indicators
    if 'configure' not in config_text.lower():
        return False, "Configuration doesn't appear to be valid SR OS config"
    
    return True, "Configuration validated"

try:
    # Connect to device
    print(f"Connecting to {nokia_device['host']}...")
    connection = ConnectHandler(**nokia_device)
    print("Connected successfully")
    
    # Get hostname for filename
    hostname = connection.find_prompt().strip('#&gt; ')
    print(f"Device hostname: {hostname}")
    
    # Execute commands
    print("Setting terminal parameters...")
    connection.send_command(commands[0], expect_string=r'#')
    
    print(f"Retrieving configuration from {hostname}...")
    config = connection.send_command(commands[1], expect_string=r'#', delay_factor=2)
    
    # Validate configuration
    is_valid, message = validate_config(config)
    if not is_valid:
        print(f"Warning: {message}")
        response = input("Continue anyway? (y/n): ")
        if response.lower() != 'y':
            print("Backup cancelled")
            connection.disconnect()
            sys.exit(1)
    else:
        print(message)
    
    # Create timestamp for filename
    timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
    
    # Create filename
    filename = f"{backup_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} ({nokia_device['host']})\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")
    
except ConnectionError as e:
    print(f"Connection error: {str(e)}")
    print("Check network connectivity and device accessibility")
    sys.exit(1)
    
except Exception as e:
    print(f"An error occurred: {str(e)}")
    print(f"Error type: {type(e).__name__}")
    sys.exit(1)

print("\nBackup completed successfully!")
</code></pre>
<h2>Expected Output</h2>
<pre><code class="language-python"># Execute the script 
python3 backup.py

Connecting to 172.20.20.13...
Connected successfully
Device hostname: *A:PE1
Setting terminal parameters...
Retrieving configuration from *A:PE1...
Configuration validated
Configuration saved to backups/*A:PE1_20260121_143523.txt
File size: 5,729 bytes
Disconnected successfully

Backup completed successfully!
</code></pre>
<h1>What’s Next?</h1>
<p>You now have a working understanding of SSH connections and basic command execution with Netmiko. In Part 2, we'll use these skills to build a practical script that backs up configurations from multiple devices automatically—the kind of task that saves network engineers hours every week. We will also breakdown the code step-by-step. Even if you've never programmed before, you'll understand exactly how it works.</p>
<p>By the end, you'll have a script that automatically backs up configurations from Nokia, Cisco, and Juniper devices!</p>
<h1>Download the code</h1>
<p>All the code from this post is available on GitLab: <a href="https://gitlab.com/kgosileburu/network-automation-scripts">network-automation-week-1</a></p>
<h1>Questions or Feedback?</h1>
<p>Connect with me on LinkedIn. I'd love to hear about your automation journey!</p>
]]></content:encoded></item></channel></rss>