yGOT part 2

Code for this will be leveraged within the git repo

This will be a continuation from Part 1

We will focus more on the aspect of connecting to a device through Go code and leveraging all the gNMI methods. I plan on showing how to do the following within this post.

  • Connect to a device using the gNMI GET method to receive all NTP servers.
  • Subscribe to a device to see a biderectional stream of NTP servers.
  • Update a devices NTP servers using the SET method.
  • Delete NTP servers using the SET method.

The protobuf for gNMI can be found here and the specification can be found here both are very good reads.

Starting off in this blog I ended up leveraging Containerlab for all of the testing. Which the clab file can be found within the git repo

I also moved around a bit of main to include flags to do specific things which I will get into here in a bit.

I will do my best to walk through the relevent code.

1

The way I am interacting with the devices is through the Arista goarista module and the gnmi package because this is generally what I use quite often plus it has some helper methods. You can either build the gNMI protos out of the protobuf yourself or use the go mod provided by gnmic if you would like to.

A lot of this is extremely manual. I could have taken a different approach. Arguably this is the worlds most difficult way to programmatically update ntp servers. I feel like the learning aspect is good and the coding examples would help the right people. Otherwise, Use gNMIC for this sort of thing.

Lets take a look at the code

main.go

The createjson is the same go code as before that existed within main to create ygot structs. connectgnmi which I will dive into is the package locally that will use all the gRPC methods for the device.

"github.com/burnyd/ygotblogpost/pkg/connectgnmi"
"github.com/burnyd/ygotblogpost/pkg/createjson"
"github.com/openconfig/ygot/ygot"
func main() {
	Target := flag.String("target", "172.20.20.2", "gnmi target")
	Port := flag.String("port", "6030", "gNMI port default is 6030")
	Username := flag.String("username", "admin", "admin")
	Password := flag.String("password", "admin", "admin")
	NtpServerAddress := flag.String("ntpserveraddress", "", "Address in which you want to render a NtpServerAddress")
	GetNtPServers := flag.Bool("getntpservers", false, "Uses the gNMI GET Method to get all the NTP Servers")
	SetNtpAddress := flag.String("setntpaddress", "", "Address in which you want to set as the ntp server.")
	DeleteNtpAddress := flag.String("deletentpaddress", "", "ntp server you want to delete")
	Subscribe := flag.Bool("subscribe", false, "Subscribe method to the ntp servers")
	flag.Parse()
	if *NtpServerAddress != "" {
		createjson.CreateNtpJson(ygot.String(*NtpServerAddress))
	}
	if *SetNtpAddress != "" {
		connectgnmi.Set(*Target, *Port, *Username, *Password, *SetNtpAddress)
	}
	if *DeleteNtpAddress != "" {
		connectgnmi.Delete(*Target, *Port, *Username, *Password, *DeleteNtpAddress)
	}
	if *GetNtPServers == true {
		connectgnmi.Get(*Target, *Port, *Username, *Password)
	}
	if *Subscribe == true {
		connectgnmi.Subscribe(*Target, *Port, *Username, *Password)
	}
	if flag.NFlag() == 0 {
		fmt.Println("You need to enter a thing please!")
	}

This is mostly self explanatory other than the NtpServerAddress part. If that exists then it will render JSON for NTP like we did in part 1.

➜  ygotblogpost git:(main) go run main.go --ntpserveraddress 1.2.3.4
{
  "openconfig-system:ntp": {
    "servers": {
      "server": [
        {
          "address": "1.2.3.4",
          "config": {
            "address": "1.2.3.4"
          }
        }
      ]
    }
  }
}
Get request

This binary will leverage the gNMI GET request to check for the NTP server paths. The interesting part is that is uses the yGOT unmarshall method.

pkg/ocsystem/ocsystem.go

// Unmarshal unmarshals data, which must be RFC7951 JSON format, into
// destStruct, which must be non-nil and the correct GoStruct type. It returns
// an error if the destStruct is not found in the schema or the data cannot be
// unmarshaled. The supplied options (opts) are used to control the behaviour
// of the unmarshal function - for example, determining whether errors are
// thrown for unknown fields in the input JSON.
func Unmarshal(data []byte, destStruct ygot.GoStruct, opts ...ytypes.UnmarshalOpt) error {
	tn := reflect.TypeOf(destStruct).Elem().Name()
	schema, ok := SchemaTree[tn]
	if !ok {
		return fmt.Errorf("could not find schema for type %s", tn )
	}
	var jsonTree interface{}
	if err := json.Unmarshal([]byte(data), &jsonTree); err != nil {
		return err
	}
	return ytypes.Unmarshal(schema, destStruct, jsonTree, opts...)
}

What does this mean? Anytime we call the unmarshall method from the ygot created ocsystem package and pass in the json data and a ygot struct it will then unmarshall it. More on this in a few.

Taking a look at the relevent parts of pkg/connectgnmi/getgnmi.go

func Get(Target, Port, Username, Password string) {
	var cfg = &gnmi.Config{
		Addr:     Target + ":" + Port,
		Username: Username,
		Password: Password,
	}
	paths := []string{"/openconfig-system:system/ntp/servers/server/config[address=*]"}
	var origin = "openconfig"
	ctx := gnmi.NewContext(context.Background(), cfg)
	client, err := gnmi.Dial(cfg)
	if err != nil {
		log.Fatal(err)
	}

	req, err := gnmi.NewGetRequest(gnmi.SplitPaths(paths), origin)
	if err != nil {
		log.Fatal(err)
	}
	if cfg.Addr != "" {
		if req.Prefix == nil {
			req.Prefix = &pb.Path{}
		}
		req.Prefix.Target = cfg.Addr
	}

	Get, err := GetReq(ctx, client, req)
	if err != nil {
		log.Fatal(err)
	}
	if len(Get) > 0 {
		fmt.Print("This is a string version of the servers \n")
		fmt.Print(string(Get), "\n")
		UnmarshallJson(Get)
	} else {
		fmt.Println("Get request did not receive any ntp servers")
	}
}

This simply calls a gnmi GET request with the proper path. At the very end there is the interesting part. It will print it out as a string and also call the UnmarshallJson function.

The UnmarshallJson function.

func UnmarshallJson(data []byte) {
	ReturnJson := &ocsystem.System_Ntp_Server{}

	if err := ocsystem.Unmarshal(data, ReturnJson); err != nil {
		panic(fmt.Sprintf("Cannot unmarshal JSON: %v", err))
	}
	fmt.Print("\n")
	fmt.Print("This is a Unmarshalled version of the data \n")
	fmt.Print(string(*ReturnJson.Address))
}

This does not look like much here but what is going on is we are using the same struct for &ocsystem.System_Ntp_Server{} as we used in the part 1 example. We then take the response called data and unmarshall this. This would then give us typed access to everything as we can see for example fmt.Print(string(*ReturnJson.Address))

A further look at ocsystem.System_Ntp_Server{}

// System_Ntp_Server represents the /openconfig-system/system/ntp/servers/server YANG schema element.
type System_Ntp_Server struct {
	Address	*string	`path:"config/address|address" module:"openconfig-system/openconfig-system|openconfig-system"`
	AssociationType	E_OpenconfigSystem_Server_AssociationType	`path:"config/association-type" module:"openconfig-system/openconfig-system"`
	Iburst	*bool	`path:"config/iburst" module:"openconfig-system/openconfig-system"`
	Offset	*uint64	`path:"state/offset" module:"openconfig-system/openconfig-system"`
	PollInterval	*uint32	`path:"state/poll-interval" module:"openconfig-system/openconfig-system"`
	Port	*uint16	`path:"config/port" module:"openconfig-system/openconfig-system"`
	Prefer	*bool	`path:"config/prefer" module:"openconfig-system/openconfig-system"`
	RootDelay	*uint32	`path:"state/root-delay" module:"openconfig-system/openconfig-system"`
	RootDispersion	*uint64	`path:"state/root-dispersion" module:"openconfig-system/openconfig-system"`
	Stratum	*uint8	`path:"state/stratum" module:"openconfig-system/openconfig-system"`
	Version	*uint8	`path:"config/version" module:"openconfig-system/openconfig-system"`
}

So we can marshall practically any fields that exist within the System_Ntp_Server. Which is rather interesting!

go run main.go --getntpservers
This is a string version of the servers
{"openconfig-system:address":"1.2.3.4"}

This is a Unmarshalled version of the data
1.2.3.4
Set request

Lets go ahead and burn it all down! 1.2.3.4 that is. Lets just up and get rid of it.

This will call the connectgnmi.Delete function.

var setOps []*gnmi.Operation

	op := &gnmi.Operation{
		Type:   "delete",
		Path:   gnmi.SplitPath(paths[0]),
		Origin: origin,
	}

	setOps = append(setOps, op)

	err = gnmi.Set(ctx, client, setOps)
	if err != nil {
		log.Fatal(err)
	} else {
		log.Print("ntp server Deleted: ", ntpserver)
	}

Within the gnmi operations which can be found here it is a helper within the goarsita gnmi module that will tell the set request to do specific things you can take a look at all the methods that connect to it.

We are simply setting up most of the data that comes within the SetRequest RPC which can be found here which takes in the NTP server path, openconfig origin and Path Delete.

go run main.go --deletentpaddress 1.2.3.4
2023/11/21 12:21:21
 Trying Path: [/openconfig-system:system/ntp/openconfig-system:servers/openconfig-system:server[address=1.2.3.4]]
2023/11/21 12:21:22 ntp server Deleted: 1.2.3.4

Its gone forever! Or is it? Lets go ahead and use this go code to update it.

	op := &gnmi.Operation{
		Type:   "update",
		Path:   gnmi.SplitPath(paths[0]),
		Origin: origin,
		Val:    ntpserver,
	}

Set the NTP server

go run main.go --setntpaddress 1.2.3.4
2023/11/21 12:23:05
 trying to update with ntp server address: 1.2.3.4
2023/11/21 12:23:05
 Trying Path: [/openconfig-system:system/ntp/openconfig-system:servers/openconfig-system:server[address=1.2.3.4]/openconfig-system:config/address]
2023/11/21 12:23:05 ntp server configured: 1.2.3.4

For good measures lets run the GET method again.

go run main.go --getntpservers
This is a string version of the servers
{"openconfig-system:address":"1.2.3.4"}

This is a Unmarshalled version of the data
1.2.3.4
Subscribe request

There are many benefits of using gRPC but the subscribe method is one of my favorites. This allows for a end user to

methods

The code exists using go routines and channels which are great to work with. In the example I am not unmarshalling anything it is simply printing out a string of all the data. Example of using subscribe method.

go run main.go --subscribe=true
[2023-11-21T17:23:05.136993365Z] (172.20.20.2:6030) Update /system/ntp/servers/server[address=1.2.3.4]/address = 1.2.3.4
[2023-11-21T17:23:05.136993365Z] (172.20.20.2:6030) Update /system/ntp/servers/server[address=1.2.3.4]/config/address = 1.2.3.4
[2023-11-21T17:23:05.136993365Z] (172.20.20.2:6030) Update /system/ntp/servers/server[address=1.2.3.4]/state/address = 1.2.3.4