Next Step
This document goes past Getting Started by exploring YANG’s fundamental management types:
- configuration
- metrics
- RPCs
- notifications
We’ll demonstrate thru example by writing software that represents a car. This car has configuration such as speed, metrics such as the odometer, rpcs such as start and stopping the car and events such as getting a flat tire.
You can structure your code however you like, but here we are breaking this example into five separate files:
- Car main source
- Car unit test
- YANG to describe management interface
- Management source using FreeCONF
- Management unit test
1. Car main source
This is just normal code to model the car. Notice there are no references to FreeCONF library, this is just code as you might normally write it.
file : car.go
package car
import (
"container/list"
"fmt"
"math/rand"
"time"
)
// ////////////////////////
// C A R - example application
//
// This has nothing to do with FreeCONF, just an example application written in Go.
// that models a running car that can get flat tires when tires are worn.
type Car struct {
Tire []*Tire
Miles float64
Running bool
// When the tires were last rotated
LastRotation int64
// Default speed value is in yang model file and free's your code
// from hardcoded values, even if they are only default values
// units milliseconds/mile
Speed int
// How fast to apply speed. Default it 1s or "miles per second"
PollInterval time.Duration
// Listeners are common on manageable code. Having said that, listeners
// remain relevant to your application. The manage.go file is responsible
// for bridging the conversion from application to management api.
listeners *list.List
}
// CarListener for receiving car update events
type CarListener func(UpdateEvent)
// car event types
type UpdateEvent int
const (
CarStarted UpdateEvent = iota + 1
CarStopped
FlatTire
)
func (e UpdateEvent) String() string {
strs := []string{
"unknown",
"carStarted",
"carStopped",
"flatTire",
}
if int(e) < len(strs) {
return strs[e]
}
return "invalid"
}
func New() *Car {
c := &Car{
listeners: list.New(),
Speed: 1000,
PollInterval: time.Second,
}
c.NewTires()
return c
}
// Stop will take up to poll_interval seconds to come to a stop
func (c *Car) Stop() {
c.Running = false
}
func (c *Car) Start() {
if c.Running {
return
}
go func() {
c.Running = true
c.updateListeners(CarStarted)
defer func() {
c.Running = false
c.updateListeners(CarStopped)
}()
for c.Speed > 0 {
poll := time.NewTicker(c.PollInterval)
for c.Running {
for range poll.C {
c.Miles += float64(c.Speed)
for _, t := range c.Tire {
t.endureMileage(c.Speed)
if t.Flat {
c.updateListeners(FlatTire)
return
}
}
}
}
}
}()
}
// OnUpdate to listen for car events like start, stop and flat tire
func (c *Car) OnUpdate(l CarListener) Subscription {
return NewSubscription(c.listeners, c.listeners.PushBack(l))
}
func (c *Car) NewTires() {
c.Tire = make([]*Tire, 4)
c.LastRotation = int64(c.Miles)
for pos := 0; pos < len(c.Tire); pos++ {
c.Tire[pos] = &Tire{
Pos: pos,
Wear: 100,
Size: "H15",
}
}
}
func (c *Car) ReplaceTires() {
for _, t := range c.Tire {
t.Replace()
}
c.LastRotation = int64(c.Miles)
}
func (c *Car) RotateTires() {
x := c.Tire[0]
c.Tire[0] = c.Tire[1]
c.Tire[1] = c.Tire[2]
c.Tire[2] = c.Tire[3]
c.Tire[3] = x
for i, t := range c.Tire {
t.Pos = i
}
c.LastRotation = int64(c.Miles)
}
func (c *Car) updateListeners(e UpdateEvent) {
fmt.Printf("car %s\n", e)
i := c.listeners.Front()
for i != nil {
i.Value.(CarListener)(e)
i = i.Next()
}
}
// T I R E
type Tire struct {
Pos int
Size string
Flat bool
Wear float64
Worn bool
}
func (t *Tire) Replace() {
t.Wear = 100
t.Flat = false
t.Worn = false
}
func (t *Tire) checkIfFlat() {
if !t.Flat {
// emulate that the more wear a tire has, the more likely it will
// get a flat, but there is always a chance.
t.Flat = (t.Wear - (rand.Float64() * 10)) < 0
}
}
func (t *Tire) endureMileage(speed int) {
// Wear down [0.0 - 0.5] of each tire proportionally to the tire position
t.Wear -= (float64(speed) / 100) * float64(t.Pos) * rand.Float64()
t.checkIfFlat()
t.checkForWear()
}
func (t *Tire) checkForWear() bool {
return t.Wear < 20
}
///////////////////////
// U T I L
// Subscription is handle into a list.List that when closed
// will automatically remove item from list. Useful for maintaining
// a set of listeners that can easily remove themselves.
type Subscription interface {
Close() error
}
// NewSubscription is used by subscription managers to give a token
// to caller the can close to unsubscribe to events
func NewSubscription(l *list.List, e *list.Element) Subscription {
return &listSubscription{l, e}
}
type listSubscription struct {
l *list.List
e *list.Element
}
// Close will unsubscribe to events.
func (sub *listSubscription) Close() error {
sub.l.Remove(sub.e)
return nil
}
file : car.py
import freeconf.nodeutil
import time
import threading
import random
# If these strings match YANG enum ids then they will be converted automatically. Ints would
# work as well
EVENT_STARTED = "carStarted"
EVENT_STOPPED = "carStopped"
EVENT_FLAT_TIRE = "flatTire"
# Simple application, no connection to management
# C A R
# Your application code.
#
# Notice there are no reference to FreeCONF in this file. This means your
# code remains:
# - unit test-able
# - Not auto-generated from model files
# - free of annotations/tags
class Car():
def __init__(self):
# metrics/state
self.miles = 0
self.running = False
self.thread = None
# potential configuable fields
self.speed = 1000
self.new_tires()
self.poll_interval = 1.0 #secs
# Listeners are common on manageable code. Having said that, listeners
# remain relevant to your application. The manage.go file is responsible
# for bridging the conversion from application to management api.
self.listeners = []
def start(self):
if self.running:
return
self.thread = threading.Thread(target=self.run, name="Car")
self.thread.start()
def stop(self):
"""
Will take up to poll_interval seconds to come to a stop
"""
self.running = False
def run(self):
try:
self.running = True
self.update_listeners(EVENT_STARTED)
while self.running:
time.sleep(self.poll_interval)
self.miles = self.miles + self.speed
for t in self.tire:
t.endure_mileage(self.speed)
if t.flat:
self.update_listeners(EVENT_FLAT_TIRE)
return
finally:
self.running = False
self.update_listeners(EVENT_STOPPED)
def on_update(self, listener):
"""
listen for car events like start, stop and flat tire
"""
self.listeners.append(listener)
def closer():
self.listeners.remove(listener)
return closer
def update_listeners(self, event):
print(f"car {event}")
for l in self.listeners:
l(event)
def new_tires(self):
self.tire = []
for pos in range(4):
self.tire.append(Tire(pos))
self.last_rotation = self.miles
def replace_tires(self):
for t in self.tire:
t.replace()
self.last_rotation = int(self.miles)
self.start()
def rotate_tires(self):
first = self.tire[0]
self.tire[0] = self.tire[1]
self.tire[1] = self.tire[2]
self.tire[2] = self.tire[3]
self.tire[3] = first
for i in range(len(self.tire)):
self.tire[i].pos = i
self.last_rotation = int(self.miles)
class Tire:
def __init__(self, pos):
self.pos = pos
self.wear = 100
self.size = "H15"
self.flat = False
self.worn = False
def replace(self):
self.wear = 100
self.flat = False
self.worn = False
def check_if_flat(self):
if not self.flat:
# emulate that the more wear a tire has, the more likely it will
# get a flat, but there is always a chance.
self.flat = (self.wear - (random.random() * 10)) < 0
def endure_mileage(self, speed):
# Wear down [0.0 - 0.5] of each tire proportionally to the tire position
self.wear -= (float(speed) / 100) * float(self.pos) * random.random()
self.check_if_flat()
self.check_for_wear()
def check_for_wear(self):
self.wear < 20
2. Car unit test
Shown here for completeness.
file : car_test.go
package car
import (
"fmt"
"testing"
"time"
"github.com/freeconf/yang/fc"
)
// Quick test of car's features using direct access to fields and methods
// again, nothing to do with FreeCONF.
func TestCar(t *testing.T) {
c := New()
c.PollInterval = time.Millisecond
c.Speed = 1000
events := make(chan UpdateEvent)
unsub := c.OnUpdate(func(e UpdateEvent) {
fmt.Printf("got event %s\n", e)
events <- e
})
t.Log("waiting for car events...")
c.Start()
fc.AssertEqual(t, CarStarted, <-events)
fc.AssertEqual(t, FlatTire, <-events)
fc.AssertEqual(t, CarStopped, <-events)
c.ReplaceTires()
c.Start()
fc.AssertEqual(t, CarStarted, <-events)
unsub.Close()
c.Stop()
}
file : test_car.py
#!/usr/bin/env python3
import unittest
import queue
import car
class TestCar(unittest.TestCase):
# Quick test of car's features using direct access to fields and methods
def test_server(self):
c = car.Car()
c.poll_interval = 0.01
c.speed = 1000
events = queue.Queue()
def on_update(e):
print(f"got event {e}")
events.put(e)
unsub = c.on_update(on_update)
print("waiting for car events...")
c.start()
self.assertEqual(car.EVENT_STARTED, events.get())
self.assertEqual(car.EVENT_FLAT_TIRE, events.get())
self.assertEqual(car.EVENT_STOPPED, events.get())
c.replace_tires()
c.start()
self.assertEqual(car.EVENT_STARTED, events.get())
unsub()
c.replace_tires()
c.stop()
if __name__ == '__main__':
unittest.main()
3. YANG to describe management interface
Come up with YANG file for the desired management API for your car. If you need help understanding YANG files there are a lot of references on the internet including this YANG primer.
file : car.yang
// Every yang file has a single module (or sub-module) definition. The name of the module
// must match the name of the file. So module definition for "car" would be in "car.yang".
// Only exception to this rule is advanced naming schemes that introduce version into
// file name.
module car {
description "Car goes beep beep";
revision 2023-03-27; // date YYYY-MM-DD is typical but you can use any scheme
// globally unique id when used with module name should this YANG file mingles with other systems
namespace "freeconf.org";
prefix "car"; // used when importing definitions from other files which we don't use here
// While any order is fine, it does control the order of data returned in the management
// interface or the order configuration is applied. You can mix order of metrics and
// config, children, rpcs, notifications as you see fit
// begin car root config...
leaf speed {
description "how many miles the car travels in one poll interval";
type int32;
units milesPerSecond;
default 1000;
}
leaf pollInterval {
description "time between traveling ${speed} miles";
type int32;
units millisecs;
default 1000;
}
// begin car root metrics...
leaf running {
description "state of the car moving or not";
type boolean;
config false;
}
leaf miles {
description "odometer - how many miles has car moved";
config false;
type decimal64 {
fraction-digits 2;
}
}
leaf lastRotation {
description "the odometer reading of the last tire rotation";
type int64;
config false;
}
// begin children objects of car...
list tire {
description "rubber circular part that makes contact with road";
// lists are most helpful when you identify a field or fields that uniquely identifies
// the items in the list. This is not strictly neccessary.
key pos;
leaf pos {
description "numerical positions of 0 thru 3";
type int32;
}
// begin tire config...
leaf size {
description "informational information of the size of the tire";
type string;
default "H15";
}
// begin tire metrics
leaf worn {
description "a somewhat subjective but deterministic value of the amount of
wear on a tire indicating tire should be replaced soon";
config false;
type boolean;
}
leaf wear {
description "number representing the amount of wear and tear on the tire.
The more wear on a tire the more likely it is to go flat.";
config false;
type decimal64 {
fraction-digits 2;
}
}
leaf flat {
description "tire has a flat and car would be kept from running. Use
replace tire or tires to get car running again";
config false;
type boolean;
}
// begin tire RPCs...
action replace {
description "replace just this tire";
// simple rpc with no input or output.
// Side note: you could have designed this as an rpc at the root level that
// accepts tire position as a single argument but putting it here makes it
// more natural and simple to call.
}
}
// In YANG 'rpc' and 'action' are identical but for historical reasons you must only
// use 'rpc' only when on the root and 'action' when inside a container or list.
// begin car RPCs...
rpc reset {
description "reset the odometer"; // somewhat unrealistic of a real car odometer
}
rpc rotateTires {
description "rotate tires for optimal wear";
}
rpc replaceTires {
description "replace all tires with fresh tires and no wear";
}
rpc start {
description "start the car if it is not already started";
}
rpc stop {
description "stop the car if it is not already stopped";
}
// begin of car events...
notification update {
description "important state information about your car";
leaf event {
type enumeration {
enum carStarted {
// optional. by default the values start at 0 and increment 1 past the
// previous value. Numbered values may be even ever be used in your programs
// and therefore irrelevant. Here I define a value just to demonstrate I could.
value 1;
}
enum carStopped;
enum flatTire;
}
}
}
}
4. Management source using FreeCONF
Here we use FreeCONF’s nodeutil.Node that uses reflection to implement the managment functions.
This is far from the only way to implement this, you can generate code or implement your own logic that implements the node.Node
interface.
file : manage.go
package car
import (
"reflect"
"time"
"github.com/freeconf/yang/meta"
"github.com/freeconf/yang/node"
"github.com/freeconf/yang/nodeutil"
)
// ///////////////////////
// C A R M A N A G E M E N T
//
// Manage your car application using FreeCONF library according to the car.yang
// model file.
//
// Manage is root handler from car.yang. i.e. module car { ... }
func Manage(car *Car) node.Node {
// We're letting reflect do a lot of the work when the yang file matches
// the field names and methods in the objects. But we extend reflection
// to add as much custom behavior as we want
return &nodeutil.Node{
// Initial object. Note: as the tree is traversed, new Node instances
// will have different values in their Object reference
Object: car,
// implement RPCs
OnAction: func(n *nodeutil.Node, r node.ActionRequest) (node.Node, error) {
switch r.Meta.Ident() {
case "reset":
// here we implement custom handler for action just as an example
// If there was a Reset() method on car the default OnAction
// handler would be able to call Reset like all the other functions
car.Miles = 0
default:
// all the actions like start, stop and rotateTire are called
// thru reflecton here because their method names align with
// the YANG.
return n.DoAction(r)
}
return nil, nil
},
// implement yang notifications (which are really just event streams)
OnNotify: func(p *nodeutil.Node, r node.NotifyRequest) (node.NotifyCloser, error) {
switch r.Meta.Ident() {
case "update":
// can use an adhoc struct send event
sub := car.OnUpdate(func(e UpdateEvent) {
msg := struct {
Event int
}{
Event: int(e),
}
// events are nodes too
r.Send(nodeutil.ReflectChild(&msg))
})
// we return a close **function**, we are not actually closing here
return sub.Close, nil
}
// there is no default implementation at this time, all notification streams
// require custom handlers.
return p.Notify(r)
},
// implement fields that are not automatically handled by reflection.
OnRead: func(p *nodeutil.Node, r meta.Definition, t reflect.Type, v reflect.Value) (reflect.Value, error) {
if l, ok := r.(meta.Leafable); ok {
// use "units" in yang to tell what to convert.
//
// Other useful ways to intercept custom reflection reads:
// 1.) incoming reflect.Type t
// 2.) field name used in yang or some pattern of the name (suffix, prefix, regex)
// 3.) yang extension
// 4.) any combination of these
if l.Units() == "millisecs" {
return reflect.ValueOf(v.Int() / int64(time.Millisecond)), nil
}
}
return v, nil
},
// Generally the reverse of what is handled in OnRead
OnWrite: func(p *nodeutil.Node, r meta.Definition, t reflect.Type, v reflect.Value) (reflect.Value, error) {
if l, ok := r.(meta.Leafable); ok {
if l.Units() == "millisecs" {
d := time.Duration(v.Int()) * time.Millisecond
return reflect.ValueOf(d), nil
}
}
return v, nil
},
}
}
file : manage.py
from freeconf import nodeutil, val
#
# C A R M A N A G E M E N T
# Bridge from model to car app.
#
# manage is root handler from car.yang. i.e. module car { ... }
def manage(c):
# implement RPCs (action or rpc)
def action(n, req):
if req.meta.ident == 'reset':
c.miles = 0
return None
return n.do_action(req)
# implement yang notifications which are really just events
def notify(n, r):
if r.meta.ident == 'update':
def listener(event):
# events are nodes too
r.send(nodeutil.Node(object={
"event": event
}))
closer = c.on_update(listener)
return closer
return n.do_notify(r)
# implement fields that are not automatically handled by reflection.
def read(_n, meta, v):
if meta.units == 'millisecs':
return int(v * 1000)
return v
def write(_n, meta, v):
if meta.units == 'millisecs':
return float(v) / 1000 # ms to secs
return v
# Extend and Reflect form a powerful combination, we're letting reflect do a lot of the CRUD
# when the yang file matches the field names. But we extend reflection
# to add as much custom behavior as we want
return nodeutil.Node(c,
on_action = action, on_notify=notify, on_read=read, on_write=write)
5. Management unit test
Write unit test for you management API without starting a server. You can read or write configuration, call functions, listen to events or read metrics all from your unit test.
file : manage_test.go
package car
import (
"testing"
"github.com/freeconf/yang/fc"
"github.com/freeconf/yang/node"
"github.com/freeconf/yang/nodeutil"
"github.com/freeconf/yang/parser"
"github.com/freeconf/yang/source"
)
// Test the car management logic in manage.go
func TestManage(t *testing.T) {
// setup
ypath := source.Path("../yang")
mod := parser.RequireModule(ypath, "car")
app := New()
// no web server needed, just your app and management function.
brwsr := node.NewBrowser(mod, Manage(app))
root := brwsr.Root()
// read all config
currCfg, err := nodeutil.WriteJSON(sel(root.Find("?content=config")))
fc.AssertEqual(t, nil, err)
expected := `{"speed":1000,"pollInterval":1000,"tire":[{"pos":0,"size":"H15"},{"pos":1,"size":"H15"},{"pos":2,"size":"H15"},{"pos":3,"size":"H15"}]}`
fc.AssertEqual(t, expected, currCfg)
// access car and verify w/API
fc.AssertEqual(t, false, app.Running)
// setup event listener, verify events later
events := make(chan string)
unsub, err := sel(root.Find("update")).Notifications(func(n node.Notification) {
event, _ := nodeutil.WriteJSON(n.Event)
events <- event
})
fc.AssertEqual(t, nil, err)
fc.AssertEqual(t, 1, app.listeners.Len())
// write config starts car
n, err := nodeutil.ReadJSON(`{"speed":2000}`)
fc.AssertEqual(t, nil, err)
err = root.UpdateFrom(n)
fc.AssertEqual(t, nil, err)
fc.AssertEqual(t, 2000, app.Speed)
// start car
fc.AssertEqual(t, nil, justErr(sel(root.Find("start")).Action(nil)))
// should be first event
fc.AssertEqual(t, `{"event":"carStarted"}`, <-events)
fc.AssertEqual(t, true, app.Running)
// unsubscribe
unsub()
fc.AssertEqual(t, 0, app.listeners.Len())
// hit all the RPCs
fc.AssertEqual(t, nil, justErr(sel(root.Find("rotateTires")).Action(nil)))
fc.AssertEqual(t, nil, justErr(sel(root.Find("replaceTires")).Action(nil)))
fc.AssertEqual(t, nil, justErr(sel(root.Find("reset")).Action(nil)))
fc.AssertEqual(t, nil, justErr(sel(root.Find("tire=0/replace")).Action(nil)))
}
func sel(s *node.Selection, err error) *node.Selection {
if err != nil {
panic(err)
}
return s
}
func justErr(_ *node.Selection, err error) error {
return err
}
file : test_manage.py
#!/usr/bin/env python3
import unittest
from freeconf import parser, node, source, nodeutil
import manage
import queue
import car
import time
# Test the car management API in manage.py
class TestManage(unittest.TestCase):
def test_manage(self):
# where YANG files are
ypath = source.path("../yang")
mod = parser.load_module_file(ypath, 'car')
# create your empty app instance
app = car.Car()
# no web server needed, just your app instance and management entry point.
brwsr = node.Browser(mod, manage.manage(app))
# get a selection into the management API to begin interaction
root = brwsr.root()
# TEST CONFIG GET: read all the config
curr_cfg = nodeutil.json_write_str(root.find("?content=config"))
expected = '{"speed":1000,"pollInterval":1000,"tire":[{"pos":0,"size":"H15"},{"pos":1,"size":"H15"},{"pos":2,"size":"H15"},{"pos":3,"size":"H15"}]}'
self.assertEqual(expected, curr_cfg)
# verify car starts not running. Here we are checking from the car instance itsel
self.assertEqual(False, app.running)
# SETUP EVENT TEST: here we add a listener to receive events and stick them in a thread-safe
# queue for assertion checking later
events = queue.Queue()
def on_update(n):
msg = nodeutil.json_write_str(n.event)
events.put(msg)
unsub = root.find("update").notification(on_update)
# TEST CONFIG SET: write config
root.update_from(nodeutil.json_read_str('{"speed":2000}'))
self.assertEqual(2000, app.speed)
# TEST RPC: start car
root.find("start").action()
# TEST EVENT: verify first event. will block until event comes in
print("waiting for event...")
self.assertEqual('{"event":"carStarted"}', events.get())
print("event receieved.")
# TEST EVENT UNSUBSCRIBE: done listening for events
unsub()
# TEST RPCS: just hit all the RPCs for code coverage. You could easily add the
# underlying car object is changed accordingly
root.find("rotateTires").action()
root.find("replaceTires").action()
root.find("reset").action()
self.assertEqual(0, app.miles)
root.find("tire=0/replace").action()
print("done")
if __name__ == '__main__':
unittest.main()
What’s Next?
- YANG primer - to learn more about building your YANG
- Code generation - To assist with reducing the code to write and improving the compile-time safety between code and YANG file
- More examples for building your management interface using Basic, Extends and Reflect