Network Automation ramblings by Kristian Larsson
07 Jul 2020

Retro SIP video conferencing to Webex et al using a Cisco C40 TelePresence codec

I use a ten year old Cisco C40 Telepresence codec for my daily video conferencing and I love it. It lets me join Webex conferences and share the screen of my Linux computer with no hassle!

An irk for crappy UX

I grew up with IRC. Sleek, fast and efficient. Today we have things like Slack. Pretty much the polar opposite. It is slow, sluggish and I don't particularly enjoy the user experience at all.

Similarly, I feel that video conferencing of today is a rather horrible experience. Or let me be more specific; I love video conferencing, but video conferencing software on computers cause me all sorts of headache.

I've been working remote for over 5 years in a distributed team. Asynchronous tools and workflows like Gitlab issues and email are the backbone of internal team communication. Video conferencing is the perfect compliment for quickly hashing things out or just seeing the face of your colleagues. Voice only (telephony) is nowhere near it. I love video conferencing!

The bulk of my work, like emailing and writing code, is carried out on Linux. I often want to share my screen to coworkers in order to discuss some aspect of code or similar.

Video conferencing software for Linux or any non-Windows/OS X platform is mostly an abysmal experience. Most of it is confined to a browser, as there are no native clients (though many "native" programs today are really just a web browser anyway). A lot of the times it won't work, even when you can join the conference itself, something else, like screen sharing will fail.

Even on OS X or Windows, video conference software has a tendency to deal you the "I'm just going to install this upgrade" just as you are about to join a conference. This might not happen for the software you use in your team, as you use it every day, but then you have a slew of other video conferencing apps installed for the more infrequent meetings. How much in advance of your actual meeting do you need to fire up a video conferencing app to ensure you are ready on time? This is a broader issue with modern software - a form of arrogance on the developers part for their users.

Needing multiple installed video conferencing applications highlights another problem. Just like modern chat applications each live in their own universe and won't interoperate with anything else, many video conferencing or collaboration solutions are walled gardens.

I want the lions share of communications to be based on federated technology, like email or SIP, both relying on DNS as the distributed directory. It's beautiful! I'm afraid email was the last widely spread truly federated application.

My solution; a decade old video conference hardware

Anyway, so back to the Cisco C40. It is my solution to everyday video conferencing and I couldn't be happier!

In my position at Deutsche Telekom, Cisco Jabber Video is used, which happens to present a SIP interface for video conferencing. My other work is with Cisco, which naturally is based on Webex (Teams) these days. It too exposes a SIP interface.

The Cisco Telepresence line of products are originally an acquisition of Tandberg. The Cisco C40 I have was originally called the Tandberg C40 and it is of the generation where Tandberg just got acquired by Cisco, released in late 2010. I actually have three C40 of which two have a Cisco logo on the front and the third reads TANDBERG. Regardless of logo on the front, the C40 can speak SIP and so I can use it to dial into Webex meetings and Cisco Jabber Video meetings. Never any dialogues prompting me to upgrade software just before a meeting. It's rock solid and I bought it off of eBay for about £200!

Linux screen sharing

The C40 sports a HDMI input so I can connect my Linux computer and share my screen. No more hassle with things not working. Did I say it is rock solid?

I use the xmonad windows manager which is a tiling window manager and one that works exceptionally well with multiple screens through its clever virtual desktop concept. Virtual desktops aren't tied to a screen, rather you have a global list of desktops and you pick which screen should map to which virtual desktop through keyboard shortcut. Since it is a tiling window manager, window placement is an automated task. Switching a virtual desktop between two screens of different resolution, such as my normal 4K screen and the 1080p of the C40, doesn't require any manual resizing or moving of windows. I do typically need to increase font size a bit though, which I have conveniently mapped for my normal applications;

  • Spacemacs: SPC z f to enter zoom mode, followed by + / - to increase or decrease respectively
  • terminal (urxvt): ^+ / ^-
  • Firefox: ^+ / ^-

Full HD 1080p resolution

It might be old, but the C40 supports 1080p both from the camera and for screen sharing which is pretty much on par with what more modern solutions support.

The C40 is actually just a codec unit. A 1U piece of specialized hardware (I looked at some debug logs and it appears to contain FPGAs, so it's not just a PC (also see https://www.youtube.com/watch?v=34FLhwkrwoQ if you are interested in the success of Tandberg overall and some details on HW)). The camera is an external unit connected via a HDMI cable and a serial cable (which I originally assumed was for controlling the motor, but that doesn't appear to be the case). From the C40 perspective, the camera HDMI port is really just another HDMI port and you could potentially connect something else, like another computer to it. Overall the C40 has three inputs;

  • HDMI for camera (but again, you can connect something else)
  • HDMI for computer or second camera
  • DVI for computer

I use the second HDMI and the DVI port with a DVI-to-HDMI adapter to connect to my Linux workstation and have one loose HDMI cable on my desktop which I can connect to another laptop when needed.

Camera picture quality

I use a Precision 60 camera. It is built for the SX80 codec (the generation after the C40) and isn't officially supported together with the C40 but I found out through a forum post that it actually works so I bought a few and it is working great. Picture quality is superb with a 1080p picture at 60fps and coupled with 20x zoom (10x optical zoom and 2x digital) - great if you have a big offiec. Zoomed out it provides a good wide view of my room. The motors are completely quiet - I simply cannot hear when it is moving, although the zoom and focus camera makes a slight noise. Nothing that annoys you - I have to sort of actively listen for it. It is a very good camera.

Previously I used a PrecisionHD 4x (TTC8-04) camera. It's actually just 720p but still delivers better quality video than most of my colleagues who are on the latest MacBook Pro or similar new laptop. 10 year old digital camera sensors aren't great but it has pretty decent optics - big pieces of glass just don't get out of date. Unlike my main driver, the Precision 60, the motors do make some noise but still qualify as fairly quiet. I have the 12x zoom camera too, which has 1080p output and it is noticeably (or not, heh) quieter than the PrecisionHD 4x but still makes some noise, so somewhere in between the completely quiet Precision 60 and the quite quiet PrecisionHD 4x. Unfortunately, there is some form of analog noise from my 12x camera which is why I've never really used it.

I find that the camera sensor, optics and bit rate used, usually has a larger effect on qualitative experience than the sheer resolution. This is in comparison to most modern laptops, which for some weird reason come with really poor cameras. Buy a $4000 laptop and get a $4 camera. Why does the iPhone or an iPad have so much better cameras than a MacBook Pro? I haven't used either my iPhone or an iPad extensively for video conferencing professionally so I can't speak to how it works in reality. I do however use my iPhone for Facetime and Whatsapp with family, which usually results in rather crappy video, I suspect it is because both end points are behind NAT and whatever relay is used is severely rate limiting the video stream.

Modern laptops usually have crappy cameras resulting in an overall crappy picture.

Mobile phones and tablets have decent cameras but in my experience with video calls, often end up with crappy picture due to low bitrate (large blocks visible from low bit rate encoding).

In practice, my C40 with its Precision 60 achieves delivers a very high quality video experience. Even with my older PrecisionHD 4x camera, the good lenses more than compensates for its aged camera sensor providing an overall better experience than modern web cameras.

Also, I just love that all these cameras are motorized, I just never get tired of moving about my room and pan my camera to where I'm at.

Audio

Audio is great. You have to connect external speakers, so that is largely up to your own choice of speakers. My kit came equipped with a desktop microphone with a micro-XLR output that is jacked up to the XLR input of the codec. Since there's an XLR input, the choices are endless.

Echo cancellation works well and I've understood (from my colleagues) that the mic doesn't pick up much noise. They hear me well and audio quality is overall good.

Noise and echoes are probably the two most common challenges for audio conferencing. I used to work on a fairly large IP telephony system back in the day, where we used hardware DSPs for echo cancellation. I haven't kept up to date on the advances in this area but would have assumed that like for everything else, software have caught up and perhaps surpassed hardware. Every day use of video conferencing applications point in the opposite direction though. I find that there are often echoes caused by participants using laptops. I'm not quite sure why.

One effect that I've sometimes noticed (not super common but not super rare either) is that when a participant start speaking I will miss the first part of what they are saying (not related to the C40, it happens on software clients too). It is as if their local microphone was muted or the signal level was very low and it is ramped up when they start speaking. The ramp-up takes a moment during which a word or two is lost. I'm not sure what component introduces this, like if it is the video conferencing software (I've noticed it across multiple different solutions) or in the client endpoint hardware, like many microphone arrays have local echo cancellation - but I've noticed it on MacBook while not on all MacBooks. Is it a setting? Automatic microphone level adjustment in combination with something else? Nonetheless, my C40 suffers from no such problems when I'm speaking.

Another problem I've noticed with software clients are that participants tend to speak over one another and I don't mean by a small amount, like with a high latency link two participants start speaking, notice they are colliding and one or both will stop (similar to Ethernet). No, I mean, two participants will just continue speaking over each other for complete sentences. I think the problem is that the software client, when it detects that you are speaking, it will lower the audio level from remote participants causing you simply to not notice anyone else speaking. The result for anyone but the two speaking parties is a cacophony. Horrible. This doesn't happen with the C40. It has good echo cancellation circuits so I suspect it doesn't need to employ tricks like lowering the audio volume of remote participants and thus this scenario doesn't really happen.

I don't really know what would cause this ramp-up problem and speaking-over-each-other problem - it is just based on observation and what I can only assume are solutions to mitigate noise and echo problems. Feel free to reach out to me if you have any insights!

IPv6

It supports IPv6. What else needs to be said?

Standalone vs SIP infrastructure

It is possible to operate the C40 standalone and directly call to IP addresses. Incoming calls can be placed directly to the public IP address of your C40 (or through forwarded ports if you are behind NAT).

The more elegant approach is of course to use a SIP registrar that answers for like your domain, so you can get username@example.com as your SIP address, just like your email address! I have not yet gotten this far though - I just dial out to various meetings, even for one-on-one calls (I use my personal webex room).

Interoperating with other systems, i.e. what speaks SIP?

Unfortunately, not many other video conferencing solutions appear to speak SIP. Many are walled gardens.

Cisco Webex (Teams)

AFAIK, all Webex meetings support dialing in with SIP. You can reach personal rooms using sip:NAME@ORG.webex.com, for example, my personal Cisco room is sip:krlarsso@cisco.webex.com. Meetings have a unique identifier consisting of 9 digits and you can dial in to them by dialing sip:MEETING_ID@webex.com.

There is a native Webex client for OS X and Windows. Without having measured, it feels like it provides for a lower latency experience than using the Webex Teams client to connect to the same meeting. Perhaps there is an additional proxy involved, like all media goes to some central webex teams service. Perhaps that service is in the US so my video streams are bounding across the Atlantic (I'm in Sweden). Connecting with my C40 I get considerably lower lag than when using the Webex teams client. I have not done a direct comparison with the native Webex clients but have the feeling that the C40 is on par or slightly better.

Cisco Jabber Video and other Cisco collaboration solutions

Cisco offers on-premise solutions for video conferencing that are popular with many enterprises. AFAIK they all support SIP. I have friends working for companies that have such solutions and when connected using the Cisco C40 to a client on his computer, the latency is exceptionally low - a very nice experience indeed. The point of video conferencing is to take away the parts that make it feel unnatural, it should be a close to a physical meeting as possible. Latency is one of the worst enemies here. With enough lag, we get people speaking over each other.

Zoom

SIP dial-in is an extra feature for Zoom, where the organizing party need to ensure it is enabled. Zoom comes as cloud hosted or can be installed on-premise. I am familiar with the details of each option and how it influences the ability of SIP dial-in. It sure doesn't provide the always-on SIP functionality that Cisco's solutions have, which is a pity. I don't understand why SIP, not being tied to any particular hardware (people talk about SIP trunks like it was a ISDN-PRI, but common), wouldn't just be enabled per default.

Microsoft Teams

There is some form of Direct Routing option for Microsoft Teams that allow a SIP trunk to be setup so that it's possible to dial-in with SIP. I have never been invited to a meeting that have this supported.

Jitsi

Jitsi is a web video conference solution based on WebRTC. It has a component called jigasi which acts as a SIP gateway. Unfortunately, it is audio only. This was a big disappointment to me as I spent a few hours setting up Jitsi thinking it would be my one stop solution for SIP video conferencing while also being able to invite random people on the Internet to use WebRTC (after all, I am aware not everyone has a SIP video conferencing system like me).

There is Jibri, which is another component of Jitsi that can perform various video functions, like streaming to YouTube and also SIP video conferencing supposedly. Jibri is implemented by running a windowless Chrome instance. Eek. It only supports a single stream, so bridging into a Jitsi meeting would be limited to the number of Jibri instances. That would probably work for me but I stopped at virtual FB chrome - yuck.

I should probably get over my feelings on the implementation and just set it up as it would probably be rather useful since I can invite other to my Jitsi instance.

Lifesize

I have never tried but Lifesize devices should be SIP standards compliant and should work well both in conferencing and for direct calls.

Polycom

I have never tried but Polycom devices should be SIP standards compliant and should work well both in conferencing and for direct calls.

Support

The Cisco C40 and all gear of its generation is pretty much out of support. However, it does what I need so I am not very troubled of this.

It is obviously a risk running unpatched software. You can mitigate this by placing it behind a firewall and using a SIP registrar etc in between.

Software

The C40 runs the TC series software. It appears to have been superceded by EC software. Newer codecs like the SX80 can run both TC and CE.

For my use cases, TC software seems just fine. I don't know what I would gain by using CE software.

API

The C40, or rather the TC software it runs, supports multiple APIs. There is a SSH CLI to configure things and a HTTP API that you can feed XML payloads to get it to do things.

I wrote a small shell script so I can dial directly from the command line of my computers;

#!/bin/sh
# Use the Cisco C40 in my office to dial a SIP address!
#

if [ -z "$1" ]; then
  echo "ERROR: You must provide a SIP number to dial!"
  exit 1
fi

curl --request POST --data "<Command><Dial command=\"True\"><Number>$1</Number><Protocol>SIP</Protocol></Dial></Command>"  -H 'Content-Type: text/xml' -u "admin:$(pass show web/${C40_ADDRESS} | head -n1)"  http://${C40_ADDRESS}/putxml

I have the local IP address of my C40 hard-coded in the C40_ADDRESS environment variable. I can then do dial 123456789@webex.com.

What I bought

I mentioned I have three C40 units. I bought the first as a complete kit including:

  • C40 codec
  • 12x camera
  • microphone
  • remote control
  • 2x HDMI cables
  • serial cable between codec and camera
  • XLR to mini-XLR for microphone
  • power cable

Unfortunately there was some problem with the codec. After a software upgrade from 4.x to the latest TC software (7.3.21) it failed to start up properly. Fortunately I was able to get a new one from the seller. Meanwhile I was so eager to get going that I ordered another unit, just the codec, so I could start playing around. Thus, I have three codecs, of which two are working and one is broken. I suppose I'll throw away the broken one or reuse the case for some other project.

Out of curiosity, I also bought a C20 and another camera. I wanted to be able to setup a call locally and for that I obviously need two complete end points.

As it turned out, the 12x camera has some analog noise, so I'm using the second 4x zoom camera that I bought as my primary camera right now. The lense on that 12x is to die for though :/

System components

For a system like mine, you need:

  • codec unit (C20, C40, C60, C90)
  • camera
    • TTC8-02 - 1080p60fps, the 12x camera I have
    • TTC8-04 - 720p, 4x zoom the 4x camera I have
    • TTC8-05 - newer 4x camera doing 1080p
    • TTC8-06 - 2.5x zoom - seems crappy
    • TTC8-07 - 1080p60fps, Precision60, 20x zoom, this is my main driver
  • microphone
    • AudioTecnica, JAVS etc sell desk microphones with XLR connections
    • you can be creative and get anything you want
    • how about a Condor MT600 beam forming microphone array?
      • or a Sennheiser TeamConnect Ceiling 2 beam forming array so you can be anywhere in your 100 sqm living room? ;)
  • remote control (there's also a touch screen display but it's more costly)
  • a TV / monitor

You need to be aware of compatibility. The C40/C60/C90 codecs work with all the cameras listed above as they all feature that serial port to connect to the camera. Newer cameras like the Precision60 (TTC8-07) doesn't have a serial port AFAIK, instead it uses Ethernet, as the C40/C60/C90 have a secondary Ethernet, this still works (although not officially supported). The C20 however only has a single Ethernet so I'm not sure about its compatibility.

The camera cable for the SX20 looks like a HDMI fused together with something else - I'm not sure what is is compatible with as I don't have that generation of gear.

Automatic camera follows speaker

There is a component called SpeakerTrack that is able to follow the currently speaking presenter. It's a marvelous piece of technology. It uses two cameras to allow focusing on one speaker while moving the other camera to be ready to cut to the next "scene", for example an overview of all participants in a meeting. I'm not entirely sure how it works but it has a large microphone array which I assume is for localizing the speaker in a room and then probably do fine adjustments based on video analysis. I have a hard time imagining that finding the speaker could be based purely on a microphone array since it also properly finds your head regardless if you are sitting down or standing up. That has to come out of image analysis. I've seen there are debug settings related to face detection, so this seems likely. The same technology is used in the MX700/MX800 (two large monitors with camera built-in) Telepresence systems.

SpeakerTrack 60, as its called, is a large beast. Probably not something you want in a home office, but nonetheless a very cool piece of technology. I believe all the clever analysis is done in the SpeakerTrack unit itself and so it is compatible with the C40, C60 and C90. It's connected mostly like any other camera via HDMI inputs though also uses the Ethernet jack but I suspect that is mostly for management.

While SpeakerTrack is a product, there is a feature called PresenterTrack that allows the same functionality of following the currently speaking presenter but with a single camera. It does however require the Precision 60 camera and a SX80 codec at a minimum. I have no idea of the underlying implementation. The SX80 is considerably more expensive than the C40 I have, so I probably won't be upgrading for a while. They are cheaper in the US so perhaps during my next trip there I'll get the chance but in the current situation, no one knows when that will be.

Similar systems

C20

The C20 codec is a smaller codec, both physically and in terms of features and capabilities. It supports up to 720p content and has fewer inputs and outputs.

It is intended for use in smaller locations, like huddle rooms.

C60 / C90

Same capabilities as C40 in terms of resolutions (1080p) and features, just with more inputs and outputs.

All three (C40, C60 and C90) are intended for installation in (small to large) conference rooms by systems integrators and have a wide range of features for this audience. You can customize what output is displayed on different screens, camera locations, integration with automatic blinds and similar.

SX20 / SX80

This is the generation after the Cxx devices with SX20 supposedly roughly mapping to the C20 (think huddle room) whereas the SX80 is somewhere in between the C60 and C90 in number of inputs/outputs. Being of a later generation, they can run more modern software and are still supported.

The only feature I've seen that seems way cooler is the PresenterTrack feature available in the SX80.

4K

I love high resolution screens. I use a 32" 4K monitor at home so I can fit a lot of text on it. My laptops have high DPI screens. While camera streams of people are usually quite fine at 1080p or even 720p, sharing your desktop at 1080p is painful. Unfortunately the C40 is a 1080p system since all signal processing is implemented in hardware and that hardware is designed for 1080p. The same is true for the later generation SX80. The newest video conference equipment from Cisco (generation after SX80), called Webex Rooms, offer the same 1080p streams for camera feeds but support 2160p, i.e. 4K, on ports from computers. This makes a lot of sense, since you typically want the higher resolution for screen sharing. The frame rate is much lower, like 5fps for the 2160p, presumably to keep down the bit rate. I would love to have this on my C40 but again, the FPGA is simply designed for 1080p.

The lack of 4K is the biggest disadvantage of my setup but as I mentioned above, with simple ways of enlarging content, like font size in terminals, it is manageable. For normal presentations, which use large fonts and visuals, it is not an issue.

Buying one

The prices in the US is considerably lower than in Europe. You can get a C40 for $50 or less. Some of the cameras are available at $20-30. Good mics are also fairly cheap. Assembling a complete system from individual parts is likely the cheapest way as people will sell it as untested gear. People tend to overcharge for the cables though and it might be a little tricky getting hold of the special serial cable (I haven't actually checked the pin-out - perhaps it isn't that special?).

There is somewhat of a risk in buying these units though. They are old and many sellers on eBay will sell them without any warranty. You might see failures. Getting a complete system from one seller is a safer way to a working system but could cost more. If you are willing to experiment a bit, buying a few cheap pieces and trying to assemble a system out of it might be a relatively safe and cheap way.

You can also try looking at the Lifesize equipment which should also work fine as standalone SIP endpoints and comes in quite cheap.

Feel free to reach out to me with questions or your experience in this area!

Tags: SIP
29 Jan 2020

Convert XML to JSON

Any network device that has a NETCONF interface will send data using XML. NETCONF interfaces are typically YANG modeled. If you prefer JSON or YAML, you can easily convert YANG modeled data from an XML representation.

This is a hands on guide. Read on to the end if you want to understand why this can only be correctly done for YANG modeled data or why it's otherwise difficult and why you need the YANG model.

We'll use yanglint. yanglint comes as a part of libyang. Install libyang and you'll get yanglint.

Feed the XML together with the YANG model(s) describing the data into yanglint and ask for a conversion of the data by using --format json. yanglint will also validate the XML data according to YANG model.

kll@minemacs:~/yang-test$ yanglint --strict tubecats.yang data1.xml --format json 
{
  "tubecats:internet": {
    "cat": [
      {
        "name": "jingles"
      },
      {
        "name": "fluffy"
      }
    ]
  }
}

kll@minemacs:~/yang-test$ echo $?

The output is a JSON document. You can pipe it to a file or use -o FILE to specify the output filename.

The output was converted from this input XML:

<ns0:data xmlns:ns0="urn:ietf:params:xml:ns:netconf:base:1.0">
    <tc:internet xmlns:tc="http://plajjan.github.io/ns/yang/tubecats">
        <tc:cat>
            <tc:name>jingles</tc:name>
        </tc:cat>
        <tc:cat>
            <tc:name>fluffy</tc:name>
        </tc:cat>
    </tc:internet>
</ns0:data>

And here is the YANG module that defines the schema for the data:

module tubecats {
    namespace "http://plajjan.github.io/ns/yang/tubecats";
    prefix tc;

    revision 2017-03-15 {
        description "First and only version";
    }

    container internet {
        list cat {
            key name;
            leaf name {
                type string;
            }
        }
    }
}

Conversion to and from YAML

As there is no standardized representation of YANG modeled data for YAML, yanglint does not support YAML as an input or output format. However, as the encoding of data in YAML has the same concepts as JSON, it is trivial to convert from JSON to YAML or vice versa with standard tools. Here is an example Python script that will do the conversion:

#!/usr/bin/env python3
import json
import sys
import yaml

jf = open(sys.argv[1])

print(yaml.dump(json.load(jf)))

and similarly in the reverse direction:

#!/usr/bin/env python3
import json
import sys
import yaml

yf = open(sys.argv[1])

print(json.dumps(yaml.load(yf)))

To use it, we pipe the output from our XML to JSON conversion on to the Python script that does JSON to YAML conversion. Behold:

kll@minemacs:~/yang-test$ yanglint --strict tubecats.yang data1.xml --format json | ./j2y.py /dev/stdin
tubecats:internet:
cat:
- {name: jingles}
- {name: fluffy}

kll@minemacs:~/yang-test$

And again, for the reverse direction we pipe it yet another time to the YAML to JSON Python script and end up with JSON data again.

kll@minemacs:~/yang-test$ yanglint --strict tubecats.yang data1.xml --format json | ./j2y.py /dev/stdin | ./y2j.py /dev/stdin | jq
{
    "tubecats:internet": {
        "cat": [
            {
                "name": "jingles"
            },
            {
                "name": "fluffy"
            }
        ]
    }
}

I wrote the program to read a file and not stdin so when piping we give it the file /dev/stdin which then accomplishes the same thing. I also run jq at the end to nicely format the JSON output as json.dumps just writes the whole JSON string on one line.

Why is it difficult to convert XML to JSON?

XML is a markup language to express nodes. A node can be contained within another node and there can be sibling nodes. There are no constructs for things like lists (arrays) or associative lists (hashes/dicts). JSON or YAML on the other hand has constructs for lists - it is embedded in the format itself. When converting to JSON we must know if something is a list but that information is simply not contained within XML, thus there is no generic conversion that produces a standardized output.

However, with YANG we have two standardized representations with XML and JSON. These standards define what, for example a YANG list, looks like in XML or JSON. With the support of a YANG schema we can thus convert in a precise and lossless fashion between the two formats.

Tags: XML JSON YAML YANG
11 Nov 2019

What's the use of presence containers in YANG?

I got the question on what presence containers are good for - what makes them useful?

P-containers is often used a short term for presence containers, meaning a container that has a presence statement in under in. In contract there are also NP-containers, or non-presence containers, which are really just plain containers without a presence statement but sometimes it's easier being explicit what the container is about.

YANG is a rather neat data modeling language. It is simple yet expressive enough to often allow something to be modeled in multiple different ways. While we are de facto defining a model when we write something in YANG, I think of it more as describing something already existing. Like how you have a thought in your mind and when you speak it out aloud, all you do is dress it with words from the English (or some other) language. There are many ways in which you can express something in English and the meaning you convey will differ ever so slightly, yet that thought in your mind isn't shaped by the words you use to describe it. The words are there to describe the thought, not the other way around. Similarly with YANG, you have an object, something that exists in reality or in thought and now you must model it. It will very likely be a simplified model and lack some of the detail of the original but nonetheless it will be a model.

A container, in its most basic shape and form, offer nothing besides acting as a container - something that contains other things. Adding the presence statement to a container in YANG allows the presence of the container in the configuration to mean something.

Let's do a simple example. For the sake of brevity, I'm skipping various required nodes like namespace etc.

module router {
  container bgp {
    leaf asn {
      type uint32;
      description "AS number of this router";
    }
    leaf router-id {
      type uint32;
      description "router-id of this router";
    }
  }
}

On this router we can configure the AS number to be used for its BGP daemon through the configuration leaf asn. Similarly, we have the leaf router-id which can be set to the router-id of the device. The bgp container is a standard container, or NP-container, meaning that it only exists when a child node of it exists. If neither asn nor router-id is set, the bgp container won't show up in the configuration whereas if either asn or router-id is set, the bgp container will show up. Its presence or not does not carry any meaning beyond containing the asn and router-id leaf.

Now let's say we want to refine our model a bit. It's not possible to run a BGP daemon without having the asn and router-id configured, thus we make the two leaves mandatory!

module router {
  container bgp {
    leaf asn {
      type uint32;
      description "AS number of this router";
      mandatory true;
    }
    leaf router-id {
      type uint32;
      description "router-id of this router";
      mandatory true;
    }
  }
}

However, this raises the next problem. Now you always have to configure both asn and router-id, even when you don't want to run BGP! How do we fix this? We could add an enabled leaf under BGP, conveying whether BGP is enabled or not and only if it is enabled then must asn and router-id be set!

module router {
  container bgp {
    leaf enabled {
      type boolean;
      description "Enable BGP";
      default false;
    }
    leaf asn {
      type uint32;
      description "AS number of this router";
      mandatory true;
      when "../enabled='true'";
    }
    leaf router-id {
      type uint32;
      description "router-id of this router";
      mandatory true;
      when "../enabled='true'";
    }
  }
}

We also add a when statement to the asn and router-id leaves so they only show up after enabled has been set. The mandatory statement only has effect when the when statement evaluates to true. This works… but it's not natural. Remember how we aren't really defining the thing we are modeling? We are just observing it and then expressing what we see through the YANG model. There are occasions for when this when statement in combination with a mandatory true is the right solution but this is not it. I think the natural way of modeling this is by making the bgp container into a presence container!

module router {
  container bgp {
    presence bgp;
    leaf asn {
      type uint32;
      description "AS number of this router";
      mandatory true;
    }
    leaf router-id {
      type uint32;
      description "router-id of this router";
      mandatory true;
    }
  }
}

Now it becomes possible to explicitly configure the bgp container node itself. As soon as we have created the bgp node, the mandatory statements in under asn and router-id force us to also enter values for them, but without having set the bgp node, like when we simply don't want to run BGP, then we also are not required to enter the asn and router-id.

Even with bgp as a P-container, there's a reason to keep the enabled leaf; we might want to be able to configure BGP but not enable it. At least for a human, to shut down the BGP daemon, it is a lot easier to flip a single enabled leaf than it is to remove the entire BGP configuration. Having an enabled leaf allows this.

module router {
  container bgp {
    presence bgp;
    leaf enabled {
      type boolean;
      description "Enable BGP";
      default true;
    }
    leaf asn {
      type uint32;
      description "AS number of this router";
      mandatory true;
    }
    leaf router-id {
      type uint32;
      description "router-id of this router";
      mandatory true;
    }
  }
}

While my example is somewhat contrived I think it brings the point of across of what an elegant model might look like and when a P-container helps us achieve that goal. Happy modeling!

Tags: YANG
24 Sep 2019

There is no golden configuration - using Cisco NSO services for everything

Using services in Cisco NSO to enable management of the full life cycle of configuration.

1 TL;DR;

Use services in Cisco NSO for everything you configure on your devices.

2 Golden configuration

If you've been in the networking business for some time you'll likely be familiar with the term "golden configuration". A sort of master template for how configuration should look. It brings associations to something that is perfect - like a golden statue on a pedestal.

Golden_statue_on_Pont_Alexandre_III_1.jpg

It is however a flawed idea. There is no such thing as a perfect configuration. Times change and so needs the configuration.

Somewhat similarly is the concept of day 0 or day 1 (ah, an off-by-one error!?) configuration. It's the idea of a configuration you put on the device when you initially configure it. There's usually nothing defined for managing the life cycle of this "day 0" or "initial" configuration and so it becomes outdated on devices that were installed a long time ago.

The name "day 0" has a temporal association as the name implies it is something you only do on the first day whereas in reality it is something you must configure on many days - to be precise; every day that you change that configuration! I prefer to call this "base configuration" as it removes that connotation of "configure once on first day". The device base configuration is a living thing and you must manage its life cycle.

We have to be able to manage the life cycle of configuration, like:

  • adding new leaves
  • changing value of leaves, lists etc
  • removing leaves, list entries etc

For example, today we configure DNS servers:

  • 8.8.8.8
  • 1.1.1.1

Tomorrow we realize we don't want neither 8.8.8.8 nor 1.1.1.1. We want to replace those entries (in a list) with our own DNS 192.0.2.1. Changing the golden day 0 configuration on disk is simple, we just edit the file and remove two entries and add another but we must then synchronize this change to the device in our network. We must keep track of what we have added in the past so we can send the configuration delta.

3 FASTMAP and reference counting in Cisco NSO

Cisco NSO uses an algorithm known as FASTMAP to reference count configuration items that are written by the create function of services. FASTMAP is one of the foundational pillars of the seamless and convenient configuration provisioning we get with Cisco NSO. We can declaratively define what the configuration should look like and the system will figure out the rest.

In contrast, using device templates, we won't get reference counting which means that removing leaves won't happen automatically. If we have set leaf X in our golden configuration today, pushed it to a thousand devices and want to remove it tomorrow, we have to do that manually.

There seems to be a trend to use device templates for this day 0 / golden configuration style use cases in Cisco NSO and I quite frankly don't understand why. The only reason I see for using device templates at all is because they could be easier to work with, depending on your perspective. Device templates live as part of the configuration in NSO and so it is editable from the NSO CLI. For people with a networking background, this is probably more intuitive than using services and their configuration templates as one has to edit files, create NSO packages etc. However, using Cisco NSO without using services is a complete waste of potential. Get over the hurdle and start writing services for all!

Enable the power of FASTMAP. Use services for everything you configure on your devices.

Tags: NSO, NCS, network automation
29 Jul 2019

Writing a Python background worker for Cisco NSO - finale

Writing a Python background worker for Cisco NSO - finale

Having read through the previous two parts (1, 2) you know we now have an implementation that actually works and behaves, at least in regards to the most important properties, the way we want. In this last part I would like to explain some of the implementation details, in particular how we efficiently wait for events.

All daemons, or computer applications running in the background, typically have a number of things they need to continuously perform. If it's a web server, we need to look for new incoming connections and then perform the relevant HTTP query handling. A naive implementation could repeatedly ask, or poll, the socket if there is anything new. If we want to react quickly, then we would have to poll very frequently which in turn means that we will use up a lot of CPU. If there are a lot of requests then this could make sense, in fact many network interface drivers use polling as it is much more efficient than being interrupt based. Polling 1000 times per second means that we will notice anything incoming within a maximum of 1 millisecond and for each poll we can efficiently batch handle all the incoming requests (assuming there are more than 1000 request per second). If there aren't that many incoming requests though, it is probably better finding a pattern where we can sleep and be awoken only when there is a request. This is what interrupts can provide to the CPU. UNIX processes don't have interrupts, instead there are signals which act in a similar way and can interrupt what the program is currently doing. It's often used for stopping the application (KILL signal) or reconfiguring it (HUP signal).

There are good use cases for signals but overall they are not the primary means of driving an application control flow. They are meant as simple inputs to a UNIX process from the outside.

Smart people realized many moons ago that we needed ways to efficiently wait for input and this is why we have things like select() and poll() which can efficiently wait for a file descriptor to become readable.

By efficiently, I mean it is able to wait for something to happen without consuming a lot of CPU resources yet is able to immediately wake up and return when something has happened. Not only can select() efficiently wait for something to happen on a file descriptor, it can wait on multiple file descriptors.

Everything in UNIX is a file, which means that sockets have file descriptors too, so we can wait on a bunch of sockets and multiple files all using the same select() call. This is the basis for many of the daemons that exist today.

Now for the background worker in NCS we have multiple events we want to observe and react to:

  • react to NCS package events (redeploy primarily)
  • react to the background worker dying (supervisor style)
  • react to changes of the configuration for our background worker (enabled or not)
  • react to HA events

Are all these sockets or file descriptors that we can wait on with a select() call? As it turns out, yes, but it wasn't very obvious from the beginning.

Thread whispering

When a package is redeployed or reloaded, NCS will stop the currently running instance by calling the teardown() function of each component thread. This is where we then in turn can call the stop() function on the various threads or processes we are running. Thus, the interface here for the incoming data is a function but we then have to propagate this information from our function to our main thread. If you remember from the first part we had a silly naive implementation of a background worker that looked like this:

# -*- mode: python; python-indent: 4 -*-
import threading
import time

import ncs
from ncs.application import Service


class BgWorker(threading.Thread):
    def run(self):
        while True:
            print("Hello from background worker")
            time.sleep(1)


class Main(ncs.application.Application):
    def setup(self):
        self.log.info('Main RUNNING')
        self.bgw = BgWorker()
        self.bgw.start()

    def teardown(self):
        self.log.info('Main FINISHED')
        self.bgw.stop()

and since it had no way to signal to the threads run() function that it should stop, it would never stop. We quickly realized this and improved it to:

# -*- mode: python; python-indent: 4 -*-
import threading
import time

import ncs
from ncs.application import Service


class BgWorker(threading.Thread):
    def __init__(self):
        threading.Thread.__init__(self)
        self._exit_flag = threading.Event()

    def run(self):
        while not self._exit_flag.wait(timeout=1):
            print("Hello from background worker")

    def stop(self):
        self._exit_flag.set()
        self.join()


class Main(ncs.application.Application):
    def setup(self):
        self.log.info('Main RUNNING')
        self.bgw = BgWorker()
        self.bgw.start()

    def teardown(self):
        self.log.info('Main FINISHED')
        self.bgw.stop()

where we use a threading.Event as the means to signal into the thread run() method that we want it to stop. In run() we read the threading.Event exit flag in a blocking fashion for a second and then perform our main functionality only to return and wait on the Event.

Using wait() on that Event means we can only wait for a single thing at a time. That's not good enough - we have multiple things we need to observe. In the main supervisor thread we replaced this with a queue since we can feed things into the queue from multiple publishers. Something like this (this isn't our actual supervisor function, just an example showing how we would wait on a queue instead):

# -*- mode: python; python-indent: 4 -*-
import threading
import time

import ncs
from ncs.application import Service


class BgWorker(threading.Thread):
    def __init__(self):
        threading.Thread.__init__(self)
        self.q = queue.Queue()

    def run(self):
        while True:
            print("Hello from background worker")

            item = self.q.get(timeout=1)
            try:
                item = self.q.get(timeout=1)
            except queue.Empty:
                continue

            if item == 'exit':
                return

    def stop(self):
        self.q.put('exit')
        self.join()


class Main(ncs.application.Application):
    def setup(self):
        self.log.info('Main RUNNING')
        self.bgw = BgWorker()
        self.bgw.start()

    def teardown(self):
        self.log.info('Main FINISHED')
        self.bgw.stop()

Thus far we have just replaced the threading.Event with a queue.Queue and unlike the Event, which effectively just carries a boolean value, the queue could carry close to anything. We use a simple string value of exit to signal the thread that it should stop. Now that we have a queue though, we can put more things on the queue and this is why a queue was picked for the supervisor.

When implementing a thread for monitoring something, the important thing to remember is that the run() loop of the thread has to be able to monitor its primary object and be signaled from the stop() function using the same method so that it can be efficiently waited upon in the run() loop.

CDB configuration changes

CDB subscribers are using a design pattern provided by Cisco and we can't influence it much. Instead we have to integrate with it. With a queue to the supervisor this becomes trivial. The config CDB subscriber simply runs as a separate thread and will take whatever updates it receives on CDB changes and publish them on the queue so the supervisor can react to it.

Monitoring HA events

HA events come from NSO over the notifications API which we access through a socket from Python. Unlike the CDB subscriber, there is no ready to go class that we can just subclass and get moving with. Instead we have to implement the thread run() method ourselves. Efficiently waiting on a socket is easy, as already covered, we can use select() for this. However, how can we signal the thread to stop using something that is selectable? I chose to implement a WaitableEvent that sends its data over a pipe, which has a file descriptor and thus is waitable. The code for that looks like this:

class WaitableEvent:
    """Provides an abstract object that can be used to resume select loops with
    indefinite waits from another thread or process. This mimics the standard
    threading.Event interface."""
    def __init__(self):
        self._read_fd, self._write_fd = os.pipe()

    def wait(self, timeout=None):
        rfds, _, _ = select.select([self._read_fd], [], [], timeout)
        return self._read_fd in rfds

    def is_set(self):
        return self.wait(0)

    def isSet(self):
        return self.wait(0)

    def clear(self):
        if self.isSet():
            os.read(self._read_fd, 1)

    def set(self):
        if not self.isSet():
            os.write(self._write_fd, b'1')

    def fileno(self):
        """Return the FD number of the read side of the pipe, allows this
        object to be used with select.select()
        """
        return self._read_fd

    def __del__(self):
        os.close(self._read_fd)
        os.close(self._write_fd)

and we can use it much the same way as threading.Event since it implements the same interface, however, note how the underlying transport is an os.pipe and we thus can use that in our select() call simply by digging out self._read_fd. Also note that I didn't write the code for this myself. After realizing what I needed I searched and the Internet delivered.

Here is the code for the HA event monitor using a WaitableEvent:

class HaEventListener(threading.Thread):
    """HA Event Listener
    HA events, like HA-mode transitions, are exposed over a notification API.
    We listen on that and forward relevant messages over the queue to the
    supervisor which can act accordingly.

    We use a WaitableEvent rather than a threading.Event since the former
    allows us to wait on it using a select loop. The HA events are received
    over a socket which can also be waited upon using a select loop, thus
    making it possible to wait for the two inputs we have using a single select
    loop.
    """
    def __init__(self, app, q):
        super(HaEventListener, self).__init__()
        self.app = app
        self.log = app.log
        self.q = q
        self.log.info('{} supervisor: init'.format(self))
        self.exit_flag = WaitableEvent()

    def run(self):
        self.app.add_running_thread(self.__class__.__name__ + ' (HA event listener)')

        self.log.info('run() HA event listener')
        from _ncs import events
        mask = events.NOTIF_HA_INFO
        event_socket = socket.socket()
        events.notifications_connect(event_socket, mask, ip='127.0.0.1', port=ncs.PORT)
        while True:
            rl, _, _ = select.select([self.exit_flag, event_socket], [], [])
            if self.exit_flag in rl:
                event_socket.close()
                return

            notification = events.read_notification(event_socket)
            # Can this fail? Could we get a KeyError here? Afraid to catch it
            # because I don't know what it could mean.
            ha_notif_type = notification['hnot']['type']

            if ha_notif_type == events.HA_INFO_IS_MASTER:
                self.q.put(('ha-master', True))
            elif ha_notif_type == events.HA_INFO_IS_NONE:
                self.q.put(('ha-master', False))
            elif ha_notif_type == events.HA_INFO_SLAVE_INITIALIZED:
                self.q.put(('ha-master', False))

    def stop(self):
        self.exit_flag.set()
        self.join()
        self.app.del_running_thread(self.__class__.__name__ + ' (HA event listener)')

It selects on the exit_flag (which is a WaitableEvent) and the event socket itself. The stop() method simply sets the WaitableEvent. If exit_flag is readable it means the thread should exit while if the event_socket is readable we have a HA event.

We use multiple threads with different methods so we can efficiently monitor different classes of objects.

Child process liveness monitor

If the process we started to run the background worker function dies for whatever reason, we want to notice this and restart it. How can we efficiently monitor the liveness of a child process?

This was the last thing we wanted to monitor that remained as a half busy poll. The supervisor would wait for things coming in on the supervisor queue for one second, then go and check if the child process was alive only to continue monitoring the queue.

The supervisor run() function:

def run(self):
    self.app.add_running_thread(self.name + ' (Supervisor)')

    while True:
        should_run = self.config_enabled and (not self.ha_enabled or self.ha_master)

        if should_run and (self.worker is None or not self.worker.is_alive()):
            self.log.info("Background worker process should run but is not running, starting")
            if self.worker is not None:
                self.worker_stop()
            self.worker_start()
        if self.worker is not None and self.worker.is_alive() and not should_run:
            self.log.info("Background worker process is running but should not run, stopping")
            self.worker_stop()

        try:
            item = self.q.get(timeout=1)
        except queue.Empty:
            continue

        k, v = item
        if k == 'exit':
            return
        elif k == 'enabled':
            self.config_enabled = v

This irked me. Waking up once a second to check on the child process doesn't exactly qualify as busy polling - only looping once a second won't increase CPU utilization by much, yet child processes dying should be enough of a rare event that not reacting quicker than 1 second is still good enough. It was a simple and pragmatic solution that was enough for production use. But it irked me.

I wanted to remove the last busy poll and so I started researching the problem. It turns out that it is possible, through a rather clever hack, to detect when a child process is no longer alive.

When the write end of a POSIX pipe is in the sole possession of a process and that process dies, the read end becomes readable

And so this is exactly what we've implemented.

  • setup a pipe
  • fork child process (actually 'spawn'), passing write end of pipe to child
  • close write end of pipe in parent process
  • wait for read end of pipe to become readable, which only happens when the child process has died

Since a pipe has a file descriptor we can wait on it using our select() loop and this is what we do in the later versions of bgworker.

Hiding things from the bg function

We want to make it dead simple to use the background process library. Passing in a pipe, and the logging objects that need to be set up, as described in the previous part, should have to be done by the user of our library. We want to take care of that, but how?

When we start the child process, it doesn't immediately run the user provided function. Instead we have a wrapper function that takes care of these things and then hands over control to the user provided function! Like this:

def _bg_wrapper(pipe_unused, log_q, log_config_q, log_level, bg_fun, *bg_fun_args):
    """Internal wrapper for the background worker function.

    Used to set up logging via a QueueHandler in the child process. The other end
    of the queue is observed by a QueueListener in the parent process.
    """
    queue_hdlr = logging.handlers.QueueHandler(log_q)
    root = logging.getLogger()
    root.setLevel(log_level)
    root.addHandler(queue_hdlr)

    # thread to monitor log level changes and reconfigure the root logger level
    log_reconf = LogReconfigurator(log_config_q, root)
    log_reconf.start()

    try:
        bg_fun(*bg_fun_args)
    except Exception as e:
        root.error('Unhandled error in {} - {}: {}'.format(bg_fun.__name__, type(e).__name__, e))
        root.debug(traceback.format_exc())

Note how the first argument, accepting the pipe is unused, but it is enough to receive the write end of the pipe. Then we configure logging etc and implement a big exception handler.

A selectable queue

Monitoring the child process liveness happens through a pipe which is waitable using select. As previously described though, we placed a queue at the center of the supervisor thread and send messages from other threads over this queue. Now we have a queue and a pipe to wait on, how?

We could probably abandon the queue and have those messages be sent over a pipe, which we could then select() on… but the queue is so convenient!

The multiprocessing library also has a queue which works across multiple processes. It uses a pipe under the hood to pass the messages and deals with things like sharing the the file descriptor when you spawn your child process (which is what we do). By simply switching from queue.Queue to multiprocessing.Queue (they feature the exact same interface) we have gained a pipe under the hood that we can select() on. Voilà!

Here's the code for the supervisor thread showing both the selectable queue (well, pipe) and the clever child process liveness monitor. To read the full up to date code for the whole background process library, just head over to the bgworker repo on Github.

class Process(threading.Thread):
    """Supervisor for running the main background process and reacting to
    various events
    """
    def __init__(self, app, bg_fun, bg_fun_args=None, config_path=None):
        super(Process, self).__init__()
        self.app = app
        self.bg_fun = bg_fun
        if bg_fun_args is None:
            bg_fun_args = []
        self.bg_fun_args = bg_fun_args
        self.config_path = config_path
        self.parent_pipe = None

        self.log = app.log
        self.name = "{}.{}".format(self.app.__class__.__module__,
                                   self.app.__class__.__name__)
        self.log.info("{} supervisor starting".format(self.name))

        self.vmid = self.app._ncs_id

        self.mp_ctx = multiprocessing.get_context('spawn')
        self.q = self.mp_ctx.Queue()

        # start the config subscriber thread
        if self.config_path is not None:
            self.config_subscriber = Subscriber(app=self.app, log=self.log)
            subscriber_iter = ConfigSubscriber(self.q, self.config_path)
            subscriber_iter.register(self.config_subscriber)
            self.config_subscriber.start()

        # start the HA event listener thread
        self.ha_event_listener = HaEventListener(app=self.app, q=self.q)
        self.ha_event_listener.start()

        # start the logging QueueListener thread
        hdlrs = list(_get_handler_impls(self.app._logger))
        self.log_queue = self.mp_ctx.Queue()
        self.queue_listener = logging.handlers.QueueListener(self.log_queue, *hdlrs, respect_handler_level=True)
        self.queue_listener.start()
        self.current_log_level = self.app._logger.getEffectiveLevel()

        # start log config CDB subscriber
        self.log_config_q = self.mp_ctx.Queue()
        self.log_config_subscriber = Subscriber(app=self.app, log=self.log)
        log_subscriber_iter = LogConfigSubscriber(self.log_config_q, self.vmid)
        log_subscriber_iter.register(self.log_config_subscriber)
        self.log_config_subscriber.start()

        self.worker = None

        # Read initial configuration, using two separate transactions
        with ncs.maapi.Maapi() as m:
            with ncs.maapi.Session(m, '{}_supervisor'.format(self.name), 'system'):
                # in the 1st transaction read config data from the 'enabled' leaf
                with m.start_read_trans() as t_read:
                    if config_path is not None:
                        enabled = t_read.get_elem(self.config_path)
                        self.config_enabled = bool(enabled)
                    else:
                        # if there is no config_path we assume the process is always enabled
                        self.config_enabled = True

                # In the 2nd transaction read operational data regarding HA.
                # This is an expensive operation invoking a data provider, thus
                # we don't want to incur any unnecessary locks
                with m.start_read_trans(db=ncs.OPERATIONAL) as oper_t_read:
                    # check if HA is enabled
                    if oper_t_read.exists("/tfnm:ncs-state/tfnm:ha"):
                        self.ha_enabled = True
                    else:
                        self.ha_enabled = False

                    # determine HA state if HA is enabled
                    if self.ha_enabled:
                        ha_mode = str(ncs.maagic.get_node(oper_t_read, '/tfnm:ncs-state/tfnm:ha/tfnm:mode'))
                        self.ha_master = (ha_mode == 'master')


    def run(self):
        self.app.add_running_thread(self.name + ' (Supervisor)')

        while True:
            try:
                should_run = self.config_enabled and (not self.ha_enabled or self.ha_master)

                if should_run and (self.worker is None or not self.worker.is_alive()):
                    self.log.info("Background worker process should run but is not running, starting")
                    if self.worker is not None:
                        self.worker_stop()
                    self.worker_start()
                if self.worker is not None and self.worker.is_alive() and not should_run:
                    self.log.info("Background worker process is running but should not run, stopping")
                    self.worker_stop()

                # check for input
                waitable_rfds = [self.q._reader]
                if should_run:
                    waitable_rfds.append(self.parent_pipe)

                rfds, _, _ = select.select(waitable_rfds, [], [])
                for rfd in rfds:
                    if rfd == self.q._reader:
                        k, v = self.q.get()

                        if k == 'exit':
                            return
                        elif k == 'enabled':
                            self.config_enabled = v
                        elif k == "ha-master":
                            self.ha_master = v

                    if rfd == self.parent_pipe:
                        # getting a readable event on the pipe should mean the
                        # child is dead - wait for it to die and start again
                        # we'll restart it at the top of the loop
                        self.log.info("Child process died")
                        if self.worker.is_alive():
                            self.worker.join()

            except Exception as e:
                self.log.error('Unhandled exception in the supervisor thread: {} ({})'.format(type(e).__name__, e))
                self.log.debug(traceback.format_exc())
                time.sleep(1)


    def stop(self):
        """stop is called when the supervisor thread should stop and is part of
        the standard Python interface for threading.Thread
        """
        # stop the HA event listener
        self.log.debug("{}: stopping HA event listener".format(self.name))
        self.ha_event_listener.stop()

        # stop config CDB subscriber
        self.log.debug("{}: stopping config CDB subscriber".format(self.name))
        if self.config_path is not None:
            self.config_subscriber.stop()

        # stop log config CDB subscriber
        self.log.debug("{}: stopping log config CDB subscriber".format(self.name))
        self.log_config_subscriber.stop()

        # stop the logging QueueListener
        self.log.debug("{}: stopping logging QueueListener".format(self.name))
        self.queue_listener.stop()

        # stop us, the supervisor
        self.log.debug("{}: stopping supervisor thread".format(self.name))

        self.q.put(('exit', None))
        self.join()
        self.app.del_running_thread(self.name + ' (Supervisor)')

        # stop the background worker process
        self.log.debug("{}: stopping background worker process".format(self.name))
        self.worker_stop()


    def worker_start(self):
        """Starts the background worker process
        """
        self.log.info("{}: starting the background worker process".format(self.name))
        # Instead of using the usual worker thread, we use a separate process here.
        # This allows us to terminate the process on package reload / NSO shutdown.

        # using multiprocessing.Pipe which is shareable across a spawned
        # process, while os.pipe only works, per default over to a forked
        # child
        self.parent_pipe, child_pipe = self.mp_ctx.Pipe()

        # Instead of calling the bg_fun worker function directly, call our
        # internal wrapper to set up things like inter-process logging through
        # a queue.
        args = [child_pipe, self.log_queue, self.log_config_q, self.current_log_level, self.bg_fun] + self.bg_fun_args
        self.worker = self.mp_ctx.Process(target=_bg_wrapper, args=args)
        self.worker.start()

        # close child pipe in parent so only child is in possession of file
        # handle, which means we get EOF when the child dies
        child_pipe.close()


    def worker_stop(self):
        """Stops the background worker process
        """
        if self.worker is None:
            self.log.info("{}: asked to stop worker but background worker does not exist".format(self.name))
            return
        if self.worker.is_alive():
            self.log.info("{}: stopping the background worker process".format(self.name))
            self.worker.terminate()
        self.worker.join(timeout=1)
        if self.worker.is_alive():
            self.log.error("{}: worker not terminated on time, alive: {}  process: {}".format(self, self.worker.is_alive(), self.worker))

A library with a simple user interface

As we've gone through over in these three posts, there's quite a bit of code that needs to be written to implement a proper NSO background worker. The idea was that we would write it in such a way that it could be reused. Someone wanting to implement a background worker should not have to understand, much less implement, all of this. This is why we've structured the surrounding code for running a background worker as a library that can be reused. We effectively hide the complexity by exposing a simple user interface to the developer. Using the bgworker background process library could look like this:

# -*- mode: python; python-indent: 4 -*-
import logging
import random
import sys
import time

import ncs
from ncs.application import Service

from . import background_process

def bg_worker():
    log = logging.getLogger()

    while True:
        with ncs.maapi.single_write_trans('bgworker', 'system', db=ncs.OPERATIONAL) as oper_trans_write:
            root = ncs.maagic.get_root(oper_trans_write)
            cur_val = root.bgworker.counter
            root.bgworker.counter += 1
            oper_trans_write.apply()

        log.debug("Hello from background worker process, increment counter from {} to {}".format(cur_val, cur_val+1))
        if random.randint(0, 10) == 9:
            log.error("Bad dice value")
            sys.exit(1)
        time.sleep(2)

class Main(ncs.application.Application):
    def setup(self):
        self.log.info('Main RUNNING')
        self.p = background_process.Process(self, bg_worker, config_path='/bgworker/enabled')
        self.p.start()

    def teardown(self):
        self.log.info('Main FINISHED')
        self.p.stop()

This is the example in the bgworker repo and it shows the simple worker that increments an operational state value once per second. Every now and then it dies, which then shows that the supervisor correctly monitors the child process and restarts it. You can disable it by setting /bgworker/enabled to false.

The main functionality is implemented in the bg_worker() function and we use the background process library to run that function in the background.

It is the following lines, which are part of a standard NSO Application definition, where we hook in and run the background process by instantiating background_process.Process() and feeding it the function we want it to run. Further we tell it that the path to the enable/disable leaf of this background worker is /bgworker/enabled. The library then takes over and does the needful.

class Main(ncs.application.Application):
    def setup(self):
        self.log.info('Main RUNNING')
        self.p = background_process.Process(self, bg_worker, config_path='/bgworker/enabled')
        self.p.start()

    def teardown(self):
        self.log.info('Main FINISHED')
        self.p.stop()

We aimed for a simple interface and I think we succeeded.

The idea behind placing all of this functionality into a library is that we hide the complexity from the user of our library. A developer that needs a background worker wants to spend 90% of the time on the actual functionality of the background worker rather than writing the surrounding overhead code necessary for running the background worker function. This is essentially the promise of any programming language or technique ever written - spend your time on your business logic and not on overhead tasks - nonetheless I think we accomplished what we set out to do.

Finale

And with that, we conclude the interesting parts of how to implement a background worker for Cisco NSO. I hope you've found it interesting to read about. It was fun and interesting implementing, in particular as it's been a while for me since I was this deep into the low level workings of things. I am a staunch believer that we generally need to use libraries when implementing network automation or other application/business logic so we can focus on the right set of the problems rather than interweaving say the low level details of POSIX processes with our application logic. In essence; using the right abstraction layers. Our human brains can only focus on so many things at a time and using the right abstractions is therefore crucial. Most of the time I mostly deal with high level languages touching high level things - exactly as I want it to be. However, once in a while, when the framework (NSO in this case) doesn't provide what you need (a way to run background workers), you just have to do it yourself.

I hope you will also appreciate the amount of energy that went into writing the bgworker package.py library that you can reuse. It is a rewrite of the common set of core functionality of the background workers we already had, resulting in a much better and cleaner implementation using a generic library style interface allowing anyone to use it. If you have the need for running background workers in NSO I strongly recommend that you make use of it. There's a lot of lessons learned here and starting over from scratch with a naive implementation means you will learn all of them the hard way. If bgworker doesn't fit in your architecture then feel free to give feedback and perhaps we can find a way forward together.

Tags: NSO
Other posts