What You’ll Learn

What You’ll Need

People who have been exposed to NSO’s functionality often gravitate toward and focus on the service mechanics, and rightly so. They are incredibly powerful and a core feature for the product. However, the flexible utility of using NSO actions is an underrated feature of NSO, which I want to educate others about.

NSO Actions

An NSO action is effectively an RPC call used by NSO to execute some arbitrary set of code associated with that YANG NSO Action. When you load a custom package into NSO, it extends the application functionality. Just like when you build a service, you can build out the data model for the inputs in your service, and you can build out a data model asking for inputs and outputs in YANG.

For example, the common action of sync-from is a built-in NSO action that does not store any data on its own, but when triggered, causes NSO to execute code behind the scenes to log into a network device and sync the data from the device into NSO’s CDB.

The NSO action is a combination of a YANG model, defining the constraints and data model of the inputs expected or outputs expected for the action, and then some code linked to it when the action is triggered. When an NSO action is created, just like any custom package in NSO, the YANG propagates the action to be available across all the NSO APIs (CLI, GUI, REST/RESTCONF, Python, etc.). This means with a few lines of YANG and a few lines of Python, you very quickly have an API interface exposed to any user who has access to NSO.

NSO Action Syntax

This tutorial is not meant to be a deep dive on the nitty gritty details of custom NSO actions, but rather a quick primer on what they are and how to use them. Also, for the sake of simplicity, I will focus on Python NSO actions, rather than Java.

An NSO custom action is defined in a package’s YANG file with the following syntax:

tailf:action double {
  tailf:actionpoint ACTION-NAME-action;
  input {
    leaf some-input-name {
      type string;
    }
  }
  output {
    leaf result {
      type string;
    }
  }
}

And just for comparison, looking at the NSO source YANG files (tailf-ncs-devices.yang), you can see, for example, the NSO sync-from action YANG, which is actually used by the NSO:

tailf:action sync-from {
        description
          "Synchronize the configuration by pulling from the device.";
        tailf:info "Synchronize the config by pulling from the device";
        tailf:actionpoint ncsinternal {
          tailf:internal;
        }
        input {
          container dry-run {
            presence "";
            leaf outformat {
              type outformat2;
              description
                "Report what would be done towards CDB, without
                 actually doing anything.";
            }
          }
          uses wait-for-lock;
        }
        output {
          uses sync-result;
        }
      }

You may notice additional YANG data model syntax in there, but it follows the same general pattern:

tailf:action NAME-OF-ACTION
{
  tailf:actionpoint NAME-WHERE-CODE-LINKS-TO-IT
  input
  {
    YANG-INPUT-NODES
  }
  output
  {
    YANG-OUTPUT-NODES
  }
}

Basically, what is going on there—and this will be more evident when you see a live example with everything working—is the YANG model tells the NSO application, “Hey! I have a custom set of code I want to execute, and here is the name I want the action to be called in the NSO application, and here is the YANG model constraints on the input and output.”

After your NSO Sandbox reservation is up, log into the 10.10.20.49 system install server. It already has a populated device list and a running instance. I used VS Code Remote Explorer opened up at the path of the packages folder /var/opt/ncs/packages/, but you can use vim or nano as well.

The easiest way to get started with an NSO action is to use the bash command flag in ncs-make-package to give you a dummy example within the packages folder:

cd /var/opt/ncs/packages/
ncs-make-package --service-skeleton python --action-example dummy-action-example

It will create the following folders and files:

[developer@nso packages]$ tree dummy-action-example/
dummy-action-example/
├── package-meta-data.xml
├── python
│   └── dummy_action_example
│       ├── __init__.py
│       └── main.py
├── README
├── src
│   ├── Makefile
│   └── yang
│       └── dummy-action-example.yang
├── templates
└── test
    ├── internal
    │   ├── lux
    │   │   ├── action
    │   │   │   ├── Makefile
    │   │   │   └── run.lux
    │   │   ├── Makefile
    │   │   └── service
    │   │       ├── dummy-device.xml
    │   │       ├── dummy-service.xml
    │   │       ├── Makefile
    │   │       ├── pyvm.xml
    │   │       └── run.lux
    │   └── Makefile
    └── Makefile

10 directories, 16 files
[developer@nso packages]$

The most relevant ones for this example will be the YANG file ntc-action-example/src/yang/ntc-action-example.yang and the Python file ntc-action-example/python/ntc_action_example/main.py.

Trimming Down the YANG File

First, by default, this is what the YANG file will look like:

module dummy-action-example {

  namespace "http://example.com/dummy-action-example";
  prefix dummy-action-example;

  import ietf-inet-types {
    prefix inet;
  }
  import tailf-common {
    prefix tailf;
  }
  import tailf-ncs {
    prefix ncs;
  }

  description
    "Bla bla...";

  revision 2016-01-01 {
    description
      "Initial revision.";
  }

  container action {
    tailf:action double {
      tailf:actionpoint dummy-action-example-action;
      input {
        leaf number {
          type uint8;
        }
      }
      output {
        leaf result {
          type uint16;
        }
      }
    }
  }
  list dummy-action-example {
    description "This is an RFS skeleton service";

    key name;
    leaf name {
      tailf:info "Unique service id";
      tailf:cli-allow-range;
      type string;
    }

    uses ncs:service-data;
    ncs:servicepoint dummy-action-example-servicepoint;

    // may replace this with other ways of refering to the devices.
    leaf-list device {
      type leafref {
        path "/ncs:devices/ncs:device/ncs:name";
      }
    }

    // replace with your own stuff here
    leaf dummy {
      type inet:ipv4-address;
    }
  }
}

I used the service skeleton bash flag, so we see the dummy YANG list dummy-action-example. Also, because I used an action example flag in the command, it added the container action part in the service YANG file. In this example, I am not going to use the service mechanics, so I will remove it and other unused parts of the YANG file just to keep it simple. The new YANG file is:

module dummy-action-example {

  namespace "http://example.com/dummy-action-example";
  prefix dummy-action-example;

  import ietf-inet-types {
    prefix inet;
  }
  import tailf-common {
    prefix tailf;
  }
  import tailf-ncs {
    prefix ncs;
  }

  container action {
    tailf:action double {
      tailf:actionpoint dummy-action-example-action;
      input {
        leaf number {
          type uint8;
        }
      }
      output {
        leaf result {
          type uint16;
        }
      }
    }
  }
}

Trimming Down the Python File

By default, NSO creates the Python file python/dummy_action_example/main.py, which includes Python classes for both the service YANG and the action example, as well as code to register and connect the code to the YANG model under the main class:

# -*- mode: python; python-indent: 4 -*-
import ncs
from ncs.application import Service
from ncs.dp import Action


# ---------------
# ACTIONS EXAMPLE
# ---------------
class DoubleAction(Action):
    @Action.action
    def cb_action(self, uinfo, name, kp, input, output, trans):
        self.log.info('action name: ', name)
        self.log.info('action input.number: ', input.number)

        # Updating the output data structure will result in a response
        # being returned to the caller.
        output.result = input.number * 2


# ------------------------
# SERVICE CALLBACK EXAMPLE
# ------------------------
class ServiceCallbacks(Service):

    # The create() callback is invoked inside NCS FASTMAP and
    # must always exist.
    @Service.create
    def cb_create(self, tctx, root, service, proplist):
        self.log.info('Service create(service=', service._path, ')')


    # The pre_modification() and post_modification() callbacks are optional,
    # and are invoked outside FASTMAP. pre_modification() is invoked before
    # create, update, or delete of the service, as indicated by the enum
    # ncs_service_operation op parameter. Conversely
    # post_modification() is invoked after create, update, or delete
    # of the service. These functions can be useful e.g. for
    # allocations that should be stored and existing also when the
    # service instance is removed.

    # @Service.pre_lock_create
    # def cb_pre_lock_create(self, tctx, root, service, proplist):
    #     self.log.info('Service plcreate(service=', service._path, ')')

    # @Service.pre_modification
    # def cb_pre_modification(self, tctx, op, kp, root, proplist):
    #     self.log.info('Service premod(service=', kp, ')')

    # @Service.post_modification
    # def cb_post_modification(self, tctx, op, kp, root, proplist):
    #     self.log.info('Service premod(service=', kp, ')')


# ---------------------------------------------
# COMPONENT THREAD THAT WILL BE STARTED BY NCS.
# ---------------------------------------------
class Main(ncs.application.Application):
    def setup(self):
        # The application class sets up logging for us. It is accessible
        # through 'self.log' and is a ncs.log.Log instance.
        self.log.info('Main RUNNING')

        # Service callbacks require a registration for a 'service point',
        # as specified in the corresponding data model.
        #
        self.register_service('dummy-action-example-servicepoint', ServiceCallbacks)

        # When using actions, this is how we register them:
        #
        self.register_action('dummy-action-example-action', DoubleAction)

        # If we registered any callback(s) above, the Application class
        # took care of creating a daemon (related to the service/action point).

        # When this setup method is finished, all registrations are
        # considered done and the application is 'started'.

    def teardown(self):
        # When the application is finished (which would happen if NCS went
        # down, packages were reloaded or some error occurred) this teardown
        # method will be called.

        self.log.info('Main FINISHED')

Because we are focusing just on the action, I can reduce the Python file to simply this:

# -*- mode: python; python-indent: 4 -*-
import ncs
from ncs.dp import Action


# ---------------
# ACTIONS EXAMPLE
# ---------------
class DoubleAction(Action):
    @Action.action
    def cb_action(self, uinfo, name, kp, input, output, trans):
        self.log.info('action name: ', name)
        self.log.info('action input.number: ', input.number)

        # Updating the output data structure will result in a response
        # being returned to the caller.
        output.result = input.number * 2



# ---------------------------------------------
# COMPONENT THREAD THAT WILL BE STARTED BY NCS.
# ---------------------------------------------
class Main(ncs.application.Application):
    def setup(self):
        # The application class sets up logging for us. It is accessible
        # through 'self.log' and is a ncs.log.Log instance.
        self.log.info('Main RUNNING')

        # When using actions, this is how we register them:
        #
        self.register_action('dummy-action-example-action', DoubleAction)

    def teardown(self):
        # When the application is finished (which would happen if NCS went
        # down, packages were reloaded or some error occurred) this teardown
        # method will be called.

        self.log.info('Main FINISHED')

As with any package, the YANG module needs to be compiled and the NSO packages need to be reloaded using the cd /var/opt/ncs/packages/dummy-action-example/src and make commands:

[developer@nso packages]$
[developer@nso packages]$ cd dummy-action-example/src/
[developer@nso src]$ make
mkdir -p ../load-dir
mkdir -p java/src//
/opt/ncs/current/bin/ncsc  `ls dummy-action-example-ann.yang  > /dev/null 2>&1 && echo "-a dummy-action-example-ann.yang"` \
              -c -o ../load-dir/dummy-action-example.fxs yang/dummy-action-example.yang

Next, log into NSO with ncs_cli, issue a package reload and conf mode to test out the new double action with action double number 22, then try with a string like action double number 22:

[developer@nso src]$ ncs_cli

User developer last logged in 2022-07-27T14:26:09.679318-07:00, to nso, from 192.168.254.11 using cli-ssh
developer connected from 192.168.254.11 using ssh on nso
developer@ncs# packages reload

>>> System upgrade is starting.
>>> Sessions in configure mode must exit to operational mode.
>>> No configuration changes can be performed until upgrade has completed.
>>> System upgrade has completed successfully.
reload-result {
    package cisco-asa-cli-6.12
    result false
    info Java VM failed to start
}
reload-result {
    package cisco-ios-cli-6.67
    result false
    info Java VM failed to start
}
reload-result {
    package cisco-iosxr-cli-7.32
    result false
    info Java VM failed to start
}
reload-result {
    package cisco-nx-cli-5.20
    result false
    info Java VM failed to start
}
reload-result {
    package dummy-action-example
    result true
}
reload-result {
    package resource-manager
    result false
    info Java VM failed to start
}
reload-result {
    package selftest
    result true
}
reload-result {
    package svi_verify_example
    result true
}
developer@ncs#
System message at 2022-07-28 16:12:54...
    Subsystem stopped: ncs-dp-2-cisco-ios-cli-6.67:IOSDp
developer@ncs#
System message at 2022-07-28 16:12:54...
    Subsystem stopped: ncs-dp-4-resource-manager:AddressallocationIPvalidation
developer@ncs#
System message at 2022-07-28 16:12:54...
    Subsystem stopped: ncs-dp-3-cisco-nx-cli-5.20:NexusDp
developer@ncs#
System message at 2022-07-28 16:12:54...
    Subsystem stopped: ncs-dp-1-cisco-asa-cli-6.12:ASADp
developer@ncs# conf
Entering configuration mode terminal
developer@ncs(config)# action double ?
Possible completions:
  number  <cr>
developer@ncs(config)# action double number ?
Possible completions:
  <unsignedByte>
developer@ncs(config)# action double number 22
result 44
developer@ncs(config)# action double number QQ
--------------------------------------------^
syntax error: "QQ" is not a valid value.
developer@ncs(config)#

We can see the dummy-action-example shows up in our packages, and it executes the Python, doubling whatever integer we give it. It has a YANG model enforcing the inputs, so we are unable to give it the invalid QQ string value, thus protecting the Python code from executing a doubling action on a string.

How Did That Work?

From the YANG side, the primary connecting statement to note is tailf:actionpoint dummy-action-example-action;, and also the names of the YANG leafs used (in this case, just number under input).

Then in the Python file, note at the bottom of the Python code, under the main class in the setup function, that self.register_action('dummy-action-example-action', DoubleAction) is registering the action dummy-action-example-action to be associated with the Python class defined in that file DoubleAction.

From the Python DoubleAction class, the most important lines to note are:

        self.log.info('action name: ', name)
        self.log.info('action input.number: ', input.number)
        output.result = input.number * 2

We use the syntax input.LEAFNAME—in this case, input.number—to access the value passed in by the action input, and then display the result using the output.result leaf data model.

Conclusion

If you want to customize your own actions, feel free to play around with the Python code. The most important thing to remember is to match your input and output types in YANG to the expected input and output types in your code. Python has very flexible data types, so it is easy to forget if you are working with a string or an integer. You can also include custom libraries at the top of your main.py file, such as something like Python requests to do a REST API call to an IPAM to grab an IP address or ServiceNow to get site data.

Learn More