Welcome back! For today, I want to show you something really cool I recently learned, that has a lot of potential: the apply-macro feature inside of Junos. And to understand this feature, we will learn a lot about Junos commit scripts, XPath as well as python. So come along for the ride.
What does apply-macro do?
The apply-macro feature inside Junos enables you to, well, apply a macro to your configuration (as the name already suggests). I worked out an example for you to easier understand what’s going on.
Imagine the following: you are a service provider who has a fair amount of BGP peerings on your edge router. As you are a transit provider, you send your customers different routing feeds, depending on your needs. Some customers only receive a default route towards your network, but others need more specific routes to better handle their traffic flows. During your day-to-day work, you notice that configuring each and every customer is always the same three steps:
- You configure an interface
- You check which export policy you want to use for the customer (Specific routes or Default route)
- You configure a new BGP neighbor
Imagine, that you could somehow automate part of this process. This is exactly what we want to do. Using the apply-macro feature, we only want to configure the interface and automatically, based on the configuration, generate a BGP session with all the right values – this saves us time and decreases the risk for manual failure.
How does the configuration look like?
As I already said, we only want to configure our interface and nothing else (except the initial configuration for this to work). This could be an example for the configuration:
interfaces {
xe-0/0/4 {
description "P3 xe-0/0/4";
vlan-tagging;
unit 100 {
apply-macro PEERING {
asn 100;
neighbor 20.255.100.2;
policy DEFAULT-ROUTE;
}
vlan-id 100;
family inet {
address 20.255.100.1/30;
}
}
}
}
In my example, because I am using the MXimal Performance Lab, I will be configuring the peerings via subinterfaces. But this process simply adapts to a real life scenario. So, what do we see here?
Inside the unit definition, there is a hidden keyword (you must type it, there is no auto-complete): apply-macro followed by the name PEERING. This tells the router, that I want to apply a macro by the name of PEERING to this interface. Inside the apply-macro statement, there are some custom keywords. Like in programming, you can give them any name and value you want. In my case, I just focused ont the three most important ones for building a BGP configuration: asn, neighbor and policy.
How is the macro being applied?
Macros are applied by so-called commit-scripts. These are custom scripts, either written in python3 or SLAX, that are executed every-time a user make a commit (or commit check). In my case, I created a script called macro.py. Lets take a look at the source-code:
from junos import Junos_Configuration as root
import jcs
if __name__ == "__main__":
for unit in root.xpath("interfaces/interface/unit[apply-macro[name='PEERING']]"):
neighbor = unit.findtext("apply-macro/data[name='neighbor']/value")
asn = unit.findtext("apply-macro/data[name='asn']/value")
policy = unit.findtext("apply-macro/data[name='policy']/value")
conf = """<protocols>
<bgp>
<group>
<name>PEERINGS</name>
<neighbor>
<name>{}</name>
<export>{}</export>
<peer-as>{}</peer-as>
</neighbor>
</group>
</bgp>
</protocols>""".format(neighbor, policy, asn)
jcs.emit_change(conf, "transient-change", "xml")
There is a lot to uncover, so let’s get started.
from junos import Junos_Configuration as root
import jcs
This part is simple. We import some packages, that allow us to see the current configuration via a variable as well as doing changes to it.
if __name__ == "__main__":
This line makes sure, that the following code is only being executed, if the file is run as is and not as a module to another python file.
for unit in root.xpath("interfaces/interface/unit[apply-macro[name='PEERING']]"):
Now, it gets really tricky. The root object holds our entire configuration in the XML format. Using this format allows us to use a specific utility, called XPath, to search XML nodes inside the configuration. To better understand, lets look at the configuration of my router (trimmed to the interfaces part) in XML format:
<rpc-reply xmlns:junos="http://xml.juniper.net/junos/20.4R0/junos">
<configuration junos:changed-seconds="1726915324" junos:changed-localtime="2024-09-21 10:42:04 UTC">
...
<interfaces>
...
<interface>
<name>xe-0/0/4</name>
<description>P3 xe-0/0/4</description>
<vlan-tagging/>
<unit>
<name>100</name>
<apply-macro>
<name>PEERING</name>
<data>
<name>asn</name>
<value>100</value>
</data>
<data>
<name>neighbor</name>
<value>20.255.100.2</value>
</data>
<data>
<name>policy</name>
<value>DEFAULT-ROUTE</value>
</data>
</apply-macro>
<vlan-id>100</vlan-id>
<family>
<inet>
<address>
<name>20.255.100.1/30</name>
</address>
</inet>
</family>
</unit>
<unit>
<name>200</name>
<apply-macro>
<name>PEERING</name>
<data>
<name>asn</name>
<value>200</value>
</data>
<data>
<name>neighbor</name>
<value>20.255.200.2</value>
</data>
<data>
<name>policy</name>
<value>SPECIFICS</value>
</data>
</apply-macro>
<vlan-id>200</vlan-id>
<family>
<inet>
<address>
<name>20.255.200.1/30</name>
</address>
</inet>
</family>
</unit>
</interface>
...
</interfaces>
...
</configuration>
<cli>
<banner>[edit]</banner>
</cli>
</rpc-reply>
The XPath utility in this case always starts beginning from the <configuration> tag and only allows to do stuff inside it. In our code, we want to iterate over each interfaces unit, that has a apply-macro with the name PEERING in it. So firstly we need to find out how to get to this element. If we take a look at the output, the <unit> elements have this path: <interfaces><interface><unit>. In XPath, we remove the <> symbols and just separate the tags via a /. So we get this path: interfaces/interface/unit.
But how do we get from there to interfaces/interface/unit[apply-macro[name=’PEERING’]]?
Well we firstly want to only receive units, that have the <apply-macro> tag set. This is made possible by writing the tag name in square brackets behind the name of the parent node. As the <apply-macro> tag lives insied the <unit> node, we have to address it this way: interfaces/interface/unit[apply-macro]. If we would use this XPath expression, we would get every elemenet, that has the <apply-macro> tag set. But as there could be multiple different macro names, we want to separate them using their name, in this instance PEERING.
To filter out only the <apply-macro> tags, that are of the PEERING macro, with have to again filter with curly brackets for the <name> element inside the <apply-macro> tag and also compare it to our given name of PEERING. This then results in the line we see in the for loop:
interfaces/interface/unit[apply-macro[name='PEERING']]
To sum up, the for loop returns us every element, that has an <apply-macro> tag inside of it that itself has a <name> tag inside with the value PEERING. Phew… that was hard. The next two lines are a little bit more simple, even though they are also XPath:
neighbor = unit.findtext("apply-macro/data[name='neighbor']/value")
asn = unit.findtext("apply-macro/data[name='asn']/value")
policy = unit.findtext("apply-macro/data[name='policy']/value")
All of the lines do exactly the same, basically. The unit variable is a unit, that has the apply-macro tag with a name of PEERING. So our current hierarchy is:
<apply-macro>
<name>PEERING</name>
<data>
<name>asn</name>
<value>200</value>
</data>
<data>
<name>neighbor</name>
<value>20.255.200.2</value>
</data>
<data>
<name>policy</name>
<value>SPECIFICS</value>
</data>
</apply-macro>
We want to again use XPath to retrieve our values for each keyword. How can we do it? Well, each keyword can be found via the apply-macro/data path. Inside the <data> tag, there is a <name> tag which holds the keywords name. So what we can do is to again filter for a specific keyword using the square brackets: apply-macro/data[name=’asn’] for example. This would give us only the element, that has the <name>asn</name> tag inside of it. In order to retrieve its value, we only have to append the tag that holds the value, in this case the <value> tag. This way, with the expression apply-macro/data[name=’asn’]/value, we can find the value for a given keyword (asn in this case). We use the findtext() function in order to only retrieve the single value and save them into the corresponding variable.
After we have done this for all three keywords, we simply construct the XML configuration for the BGP group, that we want to automatically be applied:
conf = """<protocols>
<bgp>
<group>
<name>PEERINGS</name>
<neighbor>
<name>{}</name>
<export>{}</export>
<peer-as>{}</peer-as>
</neighbor>
</group>
</bgp>
</protocols>""".format(neighbor, policy, asn)
How did I obtain the XML syntax? I configured a dummy session and issued the show | display xml command to view the XML structure. After replacing the values for neighbor, export and peer-as with curly braces and using the format() function of python, we generate a valid XML configuration with our custom vairables inside of it.
The last thing to do is to emit the configuration. This is done via the following:
jcs.emit_change(conf, "transient-change", "xml")
We commit the configuration inside the conf variable as a transient-change. What’s that? These changes are only made whilst the commit script is in place and not rendered inside the configuration. This makes it harder to see the configuration, however, if we remove a macro, it automatically removes the associated configuration.
Other Prerequisites
For our script to work properly, we have to copy it to the following location /var/db/scripts/commit/. We also need to use a user that is part of the super-user class, otherwise the device will not execute our script when committing.
The next thing to do is to enable our commit script. This is done via the following configuration:
system {
scripts {
commit {
allow-transients;
file macro.py;
}
language python3;
}
}
We need to name the script by its filename, set the language to python3 and issue the allow-transients knob for it to work properly.
If we would commit this, the commit script will already be executed and most likely return an error (in case you already configured an interface with the macto) because our BGP configuration is not ready. Following is all the configuration that is still needed for this to work including some sample static routes for our sample policies to work upon:
policy-options {
policy-statement DEFAULT-ROUTE {
term 1 {
from {
route-filter 20.0.0.0/8 exact;
}
then accept;
}
term 2 {
then reject;
}
}
policy-statement SPECIFICS {
term 1 {
from {
route-filter 20.0.0.0/8 longer;
}
then accept;
}
term 2 {
then reject;
}
}
}
routing-options {
static {
route 0.0.0.0/0 next-hop 192.168.71.254;
route 20.0.0.0/8 discard;
route 20.0.1.0/24 discard;
route 20.0.2.0/24 discard;
route 20.0.0.0/24 discard;
route 20.0.3.0/24 discard;
}
autonomous-system 65001;
}
protocols {
bgp {
group PEERINGS {
type external;
family inet {
unicast;
}
}
}
}
As you can see, the DEFAULT-ROUTE policy only allows the 20.0.0.0/8 route to be sent to the peer, the SPECIFICS policy only allows to send the four /24 routes (remember, this is just an example and will most likely need to be adapted to your scenario).
Regarding BGP, we only need to create the BGP group, define its type as well as our AS number. I added the family inet manually (even though it is enabled by default if no families are named), just for you to see something.
If we now commit our configuration, the commit script should do its job silently in the background. If not, check the commit script for typos as well as your configuration.
An example
In my exmple, I configured two subinterfaces on my main interface xe-0/0/4:
interfaces {
xe-0/0/4 {
description "P3 xe-0/0/4";
vlan-tagging;
unit 100 {
apply-macro PEERING {
asn 100;
neighbor 20.255.100.2;
policy DEFAULT-ROUTE;
}
vlan-id 100;
family inet {
address 20.255.100.1/30;
}
}
unit 200 {
apply-macro PEERING {
asn 200;
neighbor 20.255.200.2;
policy SPECIFICS;
}
vlan-id 200;
family inet {
address 20.255.200.1/30;
}
}
}
}
Unit 100 will only be using the default route, whereas unit 200 will be using the specific routes. I already configured my peer to accept the BGP session. If we now commit and check the session status, we will se the following:
master@PE1.mpl> show bgp summary
Threading mode: BGP I/O
Default eBGP mode: advertise - accept, receive - accept
Groups: 2 Peers: 2 Down peers: 0
Table Tot Paths Act Paths Suppressed History Damp State Pending
inet.0
0 0 0 0 0 0
Peer AS InPkt OutPkt OutQ Flaps Last Up/Dwn State|#Active/Received/Accepted/Damped...
20.255.100.2 100 134 134 0 0 59:24 Establ
inet.0: 0/0/0/0
20.255.200.2 200 134 134 0 0 59:20 Establ
inet.0: 0/0/0/0
Both sessions are established. Let’s check the configuration for them:
master@PE1.mpl> show configuration protocols bgp
group PEERINGS {
type external;
family inet {
unicast;
}
}
Uhmmm… Where is the configuration? Right, the configuration was issued as a transient change thus not being applied to the configuration file. If we want to check the transient configuration, we have to do the following
master@PE1.mpl> show configuration | display commit-scripts
## Last commit: 2024-09-21 10:42:03 UTC by master
version 20.4R3-S3.4;
...
protocols {
bgp {
group PEERINGS {
type external;
family inet {
unicast;
}
neighbor 20.255.100.2 {
export DEFAULT-ROUTE;
peer-as 100;
}
neighbor 20.255.200.2 {
export SPECIFICS;
peer-as 200;
}
}
}
}
As you can see, the neighbors are configured exactly the way we want. If I check my other router, I can also see, that there are different numbers of routes, that are being received by the session, just like we wanted:
master@P3.mpl> show bgp summary
Threading mode: BGP I/O
Default eBGP mode: advertise - accept, receive - accept
Groups: 2 Peers: 2 Down peers: 0
Peer AS InPkt OutPkt OutQ Flaps Last Up/Dwn State|#Active/Received/Accepted/Damped...
20.255.100.1 65001 140 139 0 0 1:01:51 Establ
as100.inet.0: 1/1/1/0
20.255.200.1 65001 139 138 0 0 1:01:47 Establ
as200.inet.0: 5/6/6/0
Alright folks, this is a practical exmaple on how commit scripts as well as the apply-macro feature can safe us some time while maintaining our network. I hope you could re-create this, if not, feel free to reach out to me in the comments! Until next time.