From 56af41f15535736d5d126bdf594f3f5c99e45271 Mon Sep 17 00:00:00 2001
From: Malte Bauch <malte.bauch@tbnet.works>
Date: Mon, 13 Jun 2022 09:17:35 +0000
Subject: [PATCH] Change gnmiNotifications sent via applyUpdate to use
 IETF_JSON

See merge request danet/gosdn!331

Co-authored-by: Malte Bauch <malte.bauch@extern.h-da.de>
---
 .gitlab/ci/.test.yml                          |  2 +-
 cli/cmd/deviceSet.go                          |  2 +-
 controller/interfaces/transport/transport.go  |  2 +-
 controller/mocks/Transport.go                 | 10 +-
 controller/nucleus/errors/errors.go           | 25 +++++
 controller/nucleus/gnmi_transport.go          | 93 +++++++++++++++++--
 controller/nucleus/gnmi_transport_test.go     | 92 +++++++++++++++++-
 controller/nucleus/initialise_test.go         |  2 +-
 controller/nucleus/principalNetworkDomain.go  |  7 +-
 .../nucleus/principalNetworkDomain_test.go    |  2 +-
 .../integration/nucleusIntegration_test.go    | 11 ++-
 11 files changed, 221 insertions(+), 27 deletions(-)

diff --git a/.gitlab/ci/.test.yml b/.gitlab/ci/.test.yml
index 60fea0a97..4d033d2ea 100644
--- a/.gitlab/ci/.test.yml
+++ b/.gitlab/ci/.test.yml
@@ -5,7 +5,7 @@
         - when: on_success
     variables:
         GOSDN_LOG: "nolog"
-        GOSDN_CHANGE_TIMEOUT: "100ms"
+        GOSDN_CHANGE_TIMEOUT: "5000ms"
     artifacts:
         when: always
         reports:
diff --git a/cli/cmd/deviceSet.go b/cli/cmd/deviceSet.go
index 1c18443b5..15806d356 100644
--- a/cli/cmd/deviceSet.go
+++ b/cli/cmd/deviceSet.go
@@ -119,6 +119,6 @@ func fileContentToString(path string) (string, error) {
 func init() {
 	deviceCmd.AddCommand(deviceSetCmd)
 	deviceSetCmd.Flags().BoolVarP(&replace, "replace", "r", false, "enables replace behaviour")
-	deviceSetCmd.Flags().StringVar(&file, "json file", "", "reference the path to a file containing your changes as JSON")
+	deviceSetCmd.Flags().StringVar(&file, "file", "", "reference the path to a file containing your changes as JSON")
 	deviceSetCmd.Flags().BoolVar(&forcePush, "force-push", false, "enables the possibility to instantly push the set without commit/confirm")
 }
diff --git a/controller/interfaces/transport/transport.go b/controller/interfaces/transport/transport.go
index a0209d9b7..9d732c370 100644
--- a/controller/interfaces/transport/transport.go
+++ b/controller/interfaces/transport/transport.go
@@ -12,7 +12,7 @@ import (
 // like RESTCONF or gnmi
 type Transport interface {
 	Get(ctx context.Context, params ...string) (interface{}, error)
-	Set(ctx context.Context, payload change.Payload) error
+	Set(ctx context.Context, payload change.Payload, path string, schema *ytypes.Schema) error
 	Subscribe(ctx context.Context, params ...string) error
 	Type() string
 	ProcessResponse(resp interface{}, root interface{}, models *ytypes.Schema) error
diff --git a/controller/mocks/Transport.go b/controller/mocks/Transport.go
index ae80536a4..10fa882e3 100644
--- a/controller/mocks/Transport.go
+++ b/controller/mocks/Transport.go
@@ -63,13 +63,13 @@ func (_m *Transport) ProcessResponse(resp interface{}, root interface{}, models
 	return r0
 }
 
-// Set provides a mock function with given fields: ctx, payload
-func (_m *Transport) Set(ctx context.Context, payload change.Payload) error {
-	ret := _m.Called(ctx, payload)
+// Set provides a mock function with given fields: ctx, payload, path, schema
+func (_m *Transport) Set(ctx context.Context, payload change.Payload, path string, schema *ytypes.Schema) error {
+	ret := _m.Called(ctx, payload, path, schema)
 
 	var r0 error
-	if rf, ok := ret.Get(0).(func(context.Context, change.Payload) error); ok {
-		r0 = rf(ctx, payload)
+	if rf, ok := ret.Get(0).(func(context.Context, change.Payload, string, *ytypes.Schema) error); ok {
+		r0 = rf(ctx, payload, path, schema)
 	} else {
 		r0 = ret.Error(0)
 	}
diff --git a/controller/nucleus/errors/errors.go b/controller/nucleus/errors/errors.go
index 7883c3598..8c30a9f42 100644
--- a/controller/nucleus/errors/errors.go
+++ b/controller/nucleus/errors/errors.go
@@ -3,6 +3,8 @@ package errors
 import (
 	"fmt"
 	"reflect"
+
+	"github.com/openconfig/ygot/ygot"
 )
 
 // ErrNilClient implements the Error interface and is called if a GNMI Client is nil.
@@ -72,6 +74,17 @@ func (e ErrUnsupportedPath) Error() string {
 	return fmt.Sprintf("path %v is not supported", e.Path)
 }
 
+// ErrPathNotFound implements the Error interface and is called if the
+// given path is not supported.
+type ErrPathNotFound struct {
+	Path interface{}
+	Err  error
+}
+
+func (e ErrPathNotFound) Error() string {
+	return fmt.Sprintf("path %v not found: %v", e.Path, e.Err)
+}
+
 // ErrNotYetImplemented implements the Error interface and is called if a function
 // is not implemented yet.
 type ErrNotYetImplemented struct{}
@@ -206,3 +219,15 @@ type ErrCouldNotDelete struct {
 func (e ErrCouldNotDelete) Error() string {
 	return fmt.Sprintf("could not delete %s", e.StoreName)
 }
+
+// ErrNoNewChanges implements the Error interface and is called if a the
+// gNMI-Notification created from ygot.Diff does not contain any `updates` or
+// `deletes`.
+type ErrNoNewChanges struct {
+	Original ygot.GoStruct
+	Modified ygot.GoStruct
+}
+
+func (e ErrNoNewChanges) Error() string {
+	return fmt.Sprintf("There are no changes between %v and %v", e.Original, e.Modified)
+}
diff --git a/controller/nucleus/gnmi_transport.go b/controller/nucleus/gnmi_transport.go
index ff19f96a6..84b9e3b3d 100644
--- a/controller/nucleus/gnmi_transport.go
+++ b/controller/nucleus/gnmi_transport.go
@@ -15,6 +15,7 @@ import (
 	"code.fbi.h-da.de/danet/gosdn/forks/goarista/gnmi"
 	gpb "github.com/openconfig/gnmi/proto/gnmi"
 	"github.com/openconfig/goyang/pkg/yang"
+	"github.com/openconfig/ygot/util"
 	"github.com/openconfig/ygot/ygot"
 	"github.com/openconfig/ygot/ytypes"
 	log "github.com/sirupsen/logrus"
@@ -85,37 +86,109 @@ func (g *Gnmi) Get(ctx context.Context, params ...string) (interface{}, error) {
 }
 
 // Set takes a change.Payload struct.
-func (g *Gnmi) Set(ctx context.Context, payload change.Payload) error {
+func (g *Gnmi) Set(ctx context.Context, payload change.Payload, path string, schema *ytypes.Schema) error {
+	p, err := ygot.StringToStructuredPath(path)
+	if err != nil {
+		return err
+	}
 	if g.client == nil {
 		return &errors.ErrNilClient{}
 	}
 	ctx = gnmi.NewContext(ctx, g.config)
-	return g.applyDiff(ctx, payload)
+	return g.applyDiff(ctx, payload, p, schema)
 }
 
-func (g *Gnmi) applyDiff(ctx context.Context, payload change.Payload) error {
-	op := ctx.Value(types.CtxKeyOperation)
+// isGNMINotificationEmpty checks if the given gnmi.Notification does not
+// contain any updates or deletes.
+func isGNMINotificationEmpty(n *gpb.Notification) bool {
+	if n.Update == nil || len(n.Update) == 0 {
+		if n.Delete == nil || len(n.Delete) == 0 {
+			return true
+		}
+	}
+	return false
+}
 
+func (g *Gnmi) applyDiff(ctx context.Context, payload change.Payload, path *gpb.Path, schema *ytypes.Schema) error {
 	diff, err := ygot.Diff(payload.Original, payload.Modified)
 	if err != nil {
 		return err
 	}
+
+	if isGNMINotificationEmpty(diff) {
+		return errors.ErrNoNewChanges{Original: payload.Original, Modified: payload.Modified}
+	}
+
+	var json []byte
+	if op := ctx.Value(types.CtxKeyOperation); op == ppb.ApiOperation_API_OPERATION_UPDATE || op == ppb.ApiOperation_API_OPERATION_REPLACE {
+		rootCopy, err := ygot.DeepCopy(schema.Root)
+		if err != nil {
+			return err
+		}
+
+		for _, u := range diff.Update {
+			opts := []ytypes.SetNodeOpt{&ytypes.InitMissingElements{}, &ytypes.TolerateJSONInconsistencies{}}
+			if err := g.SetNode(schema.RootSchema(), rootCopy, u.GetPath(), u.GetVal(), opts...); err != nil {
+				return err
+			}
+		}
+
+		ygot.PruneEmptyBranches(rootCopy)
+
+		opts := []ytypes.GetNodeOpt{
+			&ytypes.GetHandleWildcards{},
+		}
+		nodes, err := ytypes.GetNode(schema.RootSchema(), rootCopy, path, opts...)
+		if err != nil {
+			return err
+		}
+
+		if len(nodes) == 0 || err != nil || util.IsValueNil(nodes[0].Data) {
+			return errors.ErrPathNotFound{Path: path, Err: err}
+		}
+
+		json, err = ygot.Marshal7951(nodes[0].Data, &ygot.RFC7951JSONConfig{AppendModuleName: true})
+		if err != nil {
+			return err
+		}
+	}
+
+	req, err := createSetRequest(ctx, diff, json, path)
+	if err != nil {
+		return err
+	}
+
+	resp, err := g.client.Set(ctx, req)
+	log.Info(resp)
+	return err
+}
+
+func createSetRequest(ctx context.Context, diff *gpb.Notification, json []byte, path *gpb.Path) (*gpb.SetRequest, error) {
+	op := ctx.Value(types.CtxKeyOperation)
 	req := &gpb.SetRequest{}
 	if diff.Update != nil {
 		switch op {
 		case ppb.ApiOperation_API_OPERATION_UPDATE:
-			req.Update = diff.Update
+			req.Update = []*gpb.Update{{
+				Path: path,
+				Val: &gpb.TypedValue{
+					Value: &gpb.TypedValue_JsonIetfVal{JsonIetfVal: json},
+				},
+			}}
 		case ppb.ApiOperation_API_OPERATION_REPLACE:
-			req.Replace = diff.Update
+			req.Replace = []*gpb.Update{{
+				Path: path,
+				Val: &gpb.TypedValue{
+					Value: &gpb.TypedValue_JsonIetfVal{JsonIetfVal: json},
+				},
+			}}
 		default:
-			return &errors.ErrOperationNotSupported{Op: op}
+			return nil, &errors.ErrOperationNotSupported{Op: op}
 		}
 	} else if diff.Delete != nil {
 		req.Delete = diff.Delete
 	}
-	resp, err := g.client.Set(ctx, req)
-	log.Info(resp)
-	return err
+	return req, nil
 }
 
 //Subscribe subscribes to a gNMI target
diff --git a/controller/nucleus/gnmi_transport_test.go b/controller/nucleus/gnmi_transport_test.go
index e7715e7fd..deb6742db 100644
--- a/controller/nucleus/gnmi_transport_test.go
+++ b/controller/nucleus/gnmi_transport_test.go
@@ -7,9 +7,11 @@ import (
 	"testing"
 
 	"code.fbi.h-da.de/danet/gosdn/controller/interfaces/change"
+	"code.fbi.h-da.de/danet/gosdn/controller/nucleus/types"
 
 	"code.fbi.h-da.de/danet/gosdn/controller/interfaces/southbound"
 
+	ppb "code.fbi.h-da.de/danet/gosdn/api/go/gosdn/pnd"
 	spb "code.fbi.h-da.de/danet/gosdn/api/go/gosdn/southbound"
 	tpb "code.fbi.h-da.de/danet/gosdn/api/go/gosdn/transport"
 
@@ -18,6 +20,7 @@ import (
 	"code.fbi.h-da.de/danet/gosdn/models/generated/openconfig"
 	gpb "github.com/openconfig/gnmi/proto/gnmi"
 	"github.com/openconfig/goyang/pkg/yang"
+	"github.com/openconfig/ygot/ygot"
 	"github.com/openconfig/ygot/ytypes"
 	"github.com/stretchr/testify/mock"
 )
@@ -271,11 +274,21 @@ func TestGnmi_ProcessResponse(t *testing.T) {
 }
 
 func TestGnmi_Set(t *testing.T) {
+	schema, err := openconfig.Schema()
+	if err != nil {
+		t.Errorf("Set() error = %v", err)
+	}
+
+	setResponse := &gpb.SetResponse{}
+
 	type fields struct {
-		transport *Gnmi
+		transport           Gnmi
+		mockArgumentMatcher any
 	}
 	type args struct {
 		payload change.Payload
+		path    string
+		ctx     context.Context
 	}
 	tests := []struct {
 		name    string
@@ -285,17 +298,88 @@ func TestGnmi_Set(t *testing.T) {
 	}{
 		{
 			name:   "uninitialised",
-			fields: fields{&Gnmi{}},
+			fields: fields{transport: mockTransport()},
 			args: args{
 				payload: change.Payload{},
+				path:    "/",
+				ctx:     context.WithValue(context.Background(), types.CtxKeyOperation, ppb.ApiOperation_API_OPERATION_UPDATE), // nolint
 			},
 			wantErr: true,
 		},
-		// TODO: Positive test cases
+		{
+			name: "updateValue",
+			fields: fields{
+				transport: mockTransport(),
+				mockArgumentMatcher: mock.MatchedBy(func(input *gpb.SetRequest) bool {
+					if len(input.Update) == 0 {
+						return false
+					}
+					test, _ := ygot.PathToString(input.Update[0].Path)
+					return test == "/system/config/hostname"
+				}),
+			},
+			args: args{
+				payload: change.Payload{
+					Original: &openconfig.Device{
+						System: &openconfig.OpenconfigSystem_System{
+							Config: &openconfig.OpenconfigSystem_System_Config{
+								Hostname: ygot.String("oldName"),
+							},
+						},
+					},
+					Modified: &openconfig.Device{
+						System: &openconfig.OpenconfigSystem_System{
+							Config: &openconfig.OpenconfigSystem_System_Config{
+								Hostname: ygot.String("newName"),
+							},
+						},
+					},
+				},
+				path: "/system/config/hostname",
+				ctx:  context.WithValue(context.Background(), types.CtxKeyOperation, ppb.ApiOperation_API_OPERATION_UPDATE), // nolint
+			},
+			wantErr: false,
+		},
+		{
+			name: "removeValue",
+			fields: fields{
+				transport: mockTransport(),
+				mockArgumentMatcher: mock.MatchedBy(func(input *gpb.SetRequest) bool {
+					if len(input.Delete) == 0 {
+						return false
+					}
+					test, _ := ygot.PathToString(input.Delete[0])
+					return test == "/system/config/hostname"
+				}),
+			},
+			args: args{
+				payload: change.Payload{
+					Original: &openconfig.Device{
+						System: &openconfig.OpenconfigSystem_System{
+							Config: &openconfig.OpenconfigSystem_System_Config{
+								Hostname: ygot.String("oldName"),
+							},
+						},
+					},
+					Modified: &openconfig.Device{
+						System: &openconfig.OpenconfigSystem_System{
+							Config: &openconfig.OpenconfigSystem_System_Config{
+								Hostname: nil,
+							},
+						},
+					},
+				},
+				path: "/system/config/hostname",
+				ctx:  context.WithValue(context.Background(), types.CtxKeyOperation, ppb.ApiOperation_API_OPERATION_DELETE), // nolint
+			},
+			wantErr: false,
+		},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			err := tt.fields.transport.Set(context.Background(), tt.args.payload)
+			tt.fields.transport.client.(*mocks.GNMIClient).
+				On("Set", mockContext, tt.fields.mockArgumentMatcher).Return(setResponse, nil)
+			err := tt.fields.transport.Set(tt.args.ctx, tt.args.payload, tt.args.path, schema)
 			if (err != nil) != tt.wantErr {
 				t.Errorf("Set() error = %v, wantErr %v", err, tt.wantErr)
 			}
diff --git a/controller/nucleus/initialise_test.go b/controller/nucleus/initialise_test.go
index ed1d2b16c..958991f27 100644
--- a/controller/nucleus/initialise_test.go
+++ b/controller/nucleus/initialise_test.go
@@ -87,7 +87,7 @@ func targetRunner() {
 
 func mockTransport() Gnmi {
 	return Gnmi{
-		SetNode:  nil,
+		SetNode:  getMockSbi(defaultSbiID).SetNode,
 		RespChan: make(chan *gpb.SubscribeResponse),
 		Options:  newGnmiTransportOptions(),
 		client:   &mocks.GNMIClient{},
diff --git a/controller/nucleus/principalNetworkDomain.go b/controller/nucleus/principalNetworkDomain.go
index 3c75a1ae1..ad53c9694 100644
--- a/controller/nucleus/principalNetworkDomain.go
+++ b/controller/nucleus/principalNetworkDomain.go
@@ -472,7 +472,8 @@ func (pnd *pndImplementation) ChangeOND(duid uuid.UUID, operation ppb.ApiOperati
 			if err != nil {
 				return uuid.Nil, err
 			}
-			if err := ytypes.SetNode(d.SBI().Schema().RootSchema(), validatedCpy, p, typedValue); err != nil {
+			opts := []ytypes.SetNodeOpt{&ytypes.InitMissingElements{}, &ytypes.TolerateJSONInconsistencies{}}
+			if err := ytypes.SetNode(d.SBI().Schema().RootSchema(), validatedCpy, p, typedValue, opts...); err != nil {
 				return uuid.Nil, err
 			}
 		}
@@ -488,7 +489,9 @@ func (pnd *pndImplementation) ChangeOND(duid uuid.UUID, operation ppb.ApiOperati
 	callback := func(original ygot.GoStruct, modified ygot.GoStruct) error {
 		ctx := context.WithValue(context.Background(), types.CtxKeyOperation, operation) // nolint
 		payload := change.Payload{Original: original, Modified: modified}
-		return d.Transport().Set(ctx, payload)
+		pathToSet := path
+		schema := d.SBI().Schema()
+		return d.Transport().Set(ctx, payload, pathToSet, schema)
 	}
 
 	ch := NewChange(duid, d.GetModel(), validatedCpy, callback)
diff --git a/controller/nucleus/principalNetworkDomain_test.go b/controller/nucleus/principalNetworkDomain_test.go
index 907e027ab..ac28816d9 100644
--- a/controller/nucleus/principalNetworkDomain_test.go
+++ b/controller/nucleus/principalNetworkDomain_test.go
@@ -959,7 +959,7 @@ func Test_pndImplementation_Confirm(t *testing.T) {
 
 			d := mockDevice()
 			tr := d.Transport().(*mocks.Transport)
-			tr.On("Set", mockContext, mock.Anything, mock.Anything).Return(nil)
+			tr.On("Set", mockContext, mock.Anything, mock.Anything, mock.Anything).Return(nil)
 			_, err := pnd.addDevice(d)
 			if err != nil {
 				t.Error(err)
diff --git a/controller/test/integration/nucleusIntegration_test.go b/controller/test/integration/nucleusIntegration_test.go
index 333efb177..b85058a46 100644
--- a/controller/test/integration/nucleusIntegration_test.go
+++ b/controller/test/integration/nucleusIntegration_test.go
@@ -14,6 +14,7 @@ import (
 	spb "code.fbi.h-da.de/danet/gosdn/api/go/gosdn/southbound"
 	tpb "code.fbi.h-da.de/danet/gosdn/api/go/gosdn/transport"
 	"code.fbi.h-da.de/danet/gosdn/controller/interfaces/change"
+	"code.fbi.h-da.de/danet/gosdn/models/generated/openconfig"
 
 	"code.fbi.h-da.de/danet/gosdn/controller/nucleus"
 	"code.fbi.h-da.de/danet/gosdn/controller/nucleus/errors"
@@ -92,6 +93,11 @@ func testSetupIntegration() {
 }
 
 func TestGnmi_SetInvalidIntegration(t *testing.T) {
+	schema, err := openconfig.Schema()
+	if err != nil {
+		t.Errorf("Set ")
+
+	}
 	if testing.Short() {
 		t.Skip("skipping integration test")
 	}
@@ -101,6 +107,7 @@ func TestGnmi_SetInvalidIntegration(t *testing.T) {
 	type args struct {
 		ctx     context.Context
 		payload change.Payload
+		path    string
 	}
 	tests := []struct {
 		name    string
@@ -120,6 +127,7 @@ func TestGnmi_SetInvalidIntegration(t *testing.T) {
 			args: args{
 				ctx:     context.Background(),
 				payload: change.Payload{},
+				path:    "/",
 			},
 			wantErr: true,
 		},
@@ -129,6 +137,7 @@ func TestGnmi_SetInvalidIntegration(t *testing.T) {
 			args: args{
 				ctx:     context.Background(),
 				payload: change.Payload{},
+				path:    "/",
 			},
 			wantErr: true,
 		},
@@ -145,7 +154,7 @@ func TestGnmi_SetInvalidIntegration(t *testing.T) {
 				t.Errorf("SetInvalidIntegration() error = %v, wantErr %v", err, tt.wantErr)
 				return
 			}
-			err = g.Set(tt.args.ctx, tt.args.payload)
+			err = g.Set(tt.args.ctx, tt.args.payload, tt.args.path, schema)
 			if (err != nil) != tt.wantErr {
 				t.Errorf("SetInvalidIntegration() error = %v, wantErr %v", err, tt.wantErr)
 				return
-- 
GitLab