diff --git a/go.mod b/go.mod index 138007380..5bed003fd 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,8 @@ module github.com/ironcore-dev/network-operator go 1.26.0 +tool github.com/matryer/moq + require ( github.com/felix-kaestner/copy v0.0.0-20250930112410-8fbc5c5b74a5 github.com/go-crypt/crypt v0.14.15 @@ -13,6 +15,7 @@ require ( github.com/openconfig/gnoi v0.8.0 github.com/openconfig/ygot v0.34.0 github.com/pin/tftp/v3 v3.2.0 + github.com/prometheus/client_golang v1.23.2 github.com/stretchr/testify v1.11.1 github.com/tidwall/gjson v1.19.0 github.com/tidwall/sjson v1.2.5 @@ -72,12 +75,12 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/kylelemons/godebug v1.1.0 // indirect + github.com/matryer/moq v0.7.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/openconfig/goyang v1.6.3 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.20.1 // indirect diff --git a/go.sum b/go.sum index 238b513dc..78def1ee9 100644 --- a/go.sum +++ b/go.sum @@ -121,6 +121,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/matryer/moq v0.7.1 h1:/QaXqMAdOrLqlshW2z7SMS21jDi7aVrbW0wJrR+hhJk= +github.com/matryer/moq v0.7.1/go.mod h1:IabIiFkaKCyHxej25INgFR+fnOxSZFMv2LYrU+ioyDs= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= diff --git a/internal/provider/cisco/iosxr/provider_test.go b/internal/provider/cisco/iosxr/provider_test.go index 320d9944f..67120281a 100644 --- a/internal/provider/cisco/iosxr/provider_test.go +++ b/internal/provider/cisco/iosxr/provider_test.go @@ -88,64 +88,12 @@ func Test_Payload(t *testing.T) { } } -// MockClient provides a mock implementation of gnmiext.Client for testing. -type MockClient struct { - // Function fields for mocking different methods - CapabilitiesFunc func() *gnmiext.Capabilities - GetConfigFunc func(ctx context.Context, configs ...gnmiext.DataElement) error - PatchFunc func(ctx context.Context, patches ...gnmiext.DataElement) error - UpdateFunc func(ctx context.Context, updates ...gnmiext.DataElement) error - DeleteFunc func(ctx context.Context, deletes ...gnmiext.DataElement) error - GetStateFunc func(ctx context.Context, states ...gnmiext.DataElement) error -} - -var _ gnmiext.Client = (*MockClient)(nil) - -// Implement the methods that Provider uses -func (m *MockClient) Capabilities() *gnmiext.Capabilities { - if m.CapabilitiesFunc != nil { - return m.CapabilitiesFunc() - } - return nil -} - -func (m *MockClient) GetConfig(ctx context.Context, configs ...gnmiext.DataElement) error { - if m.GetConfigFunc != nil { - return m.GetConfigFunc(ctx, configs...) - } - return nil -} - -func (m *MockClient) GetState(ctx context.Context, states ...gnmiext.DataElement) error { - if m.GetStateFunc != nil { - return m.GetStateFunc(ctx, states...) - } - return nil -} - -func (m *MockClient) Patch(ctx context.Context, patches ...gnmiext.DataElement) error { - if m.PatchFunc != nil { - return m.PatchFunc(ctx, patches...) - } - return nil -} - -func (m *MockClient) Update(ctx context.Context, updates ...gnmiext.DataElement) error { - if m.UpdateFunc != nil { - return m.UpdateFunc(ctx, updates...) - } - return nil -} - -func (m *MockClient) Delete(ctx context.Context, deletes ...gnmiext.DataElement) error { - if m.DeleteFunc != nil { - return m.DeleteFunc(ctx, deletes...) - } - return nil -} - func Test_EnsureInterface(t *testing.T) { - m := &MockClient{} + m := &gnmiext.ClientMock{ + UpdateFunc: func(ctx context.Context, el ...gnmiext.DataElement) error { + return nil + }, + } p := &Provider{client: m} ctx := t.Context() @@ -186,7 +134,7 @@ func Test_EnsureInterface(t *testing.T) { } func Test_GetState(t *testing.T) { - m := &MockClient{ + m := &gnmiext.ClientMock{ GetStateFunc: func(ctx context.Context, states ...gnmiext.DataElement) error { states[0].(*PhysIfState).State = "im-state-up" return nil diff --git a/internal/transport/gnmiext/client.go b/internal/transport/gnmiext/client.go index 29c63fb8f..037b92fe1 100644 --- a/internal/transport/gnmiext/client.go +++ b/internal/transport/gnmiext/client.go @@ -10,11 +10,13 @@ import ( "fmt" "log/slog" "reflect" + "slices" "strings" + "time" cp "github.com/felix-kaestner/copy" "github.com/go-logr/logr" - gpb "github.com/openconfig/gnmi/proto/gnmi" + gnmipb "github.com/openconfig/gnmi/proto/gnmi" "github.com/openconfig/ygot/ygot" "github.com/tidwall/gjson" "google.golang.org/grpc" @@ -61,10 +63,66 @@ type Capabilities struct { SupportedModels []Model } +// mode represents the type of gNMI Set operation. +type mode uint8 + +const ( + replace mode = iota + 1 + update + del +) + +// operation pairs a data element with the Set operation to apply. +type operation struct { + el DataElement + mode mode +} + +// SetBuilder accumulates gNMI Set operations to be executed as one or +// more batched SetRequest RPCs. +type SetBuilder struct { + ops []operation + limit int +} + +// Update queues a full replacement of the given elements (gNMI replace). +func (b *SetBuilder) Update(el ...DataElement) *SetBuilder { + for _, e := range el { + b.ops = append(b.ops, operation{el: e, mode: replace}) + } + return b +} + +// Patch queues a partial merge of the given elements (gNMI update). +func (b *SetBuilder) Patch(el ...DataElement) *SetBuilder { + for _, e := range el { + b.ops = append(b.ops, operation{el: e, mode: update}) + } + return b +} + +// Delete queues removal of the given elements (gNMI delete). +func (b *SetBuilder) Delete(el ...DataElement) *SetBuilder { + for _, e := range el { + b.ops = append(b.ops, operation{el: e, mode: del}) + } + return b +} + +// Limit sets the maximum number of operations per SetRequest RPC. +// If unset or <= 0, all operations are sent in a single request. +func (b *SetBuilder) Limit(n int) *SetBuilder { + b.limit = n + return b +} + +//go:generate go tool moq -with-resets -out client_mock.go . Client + type Client interface { Capabilities() *Capabilities GetConfig(context.Context, ...DataElement) error GetState(context.Context, ...DataElement) error + Do(context.Context, *SetBuilder) error Patch(context.Context, ...DataElement) error Update(context.Context, ...DataElement) error Delete(context.Context, ...DataElement) error @@ -73,10 +131,11 @@ type Client interface { // Client is a gNMI client offering convenience methods for device configuration // using gNMI. type client struct { - gnmi gpb.GNMIClient - encoding gpb.Encoding + gnmi gnmipb.GNMIClient + encoding gnmipb.Encoding capabilities *Capabilities logger logr.Logger + target string } var _ Client = &client{} @@ -87,15 +146,15 @@ var _ Client = &client{} // By default, the client uses [slog.Default] for logging. // Use [WithLogger] to provide a custom logger. func New(ctx context.Context, conn grpc.ClientConnInterface, opts ...Option) (Client, error) { - gnmi := gpb.NewGNMIClient(conn) - res, err := gnmi.Capabilities(ctx, &gpb.CapabilityRequest{}) + gnmi := gnmipb.NewGNMIClient(conn) + res, err := gnmi.Capabilities(ctx, &gnmipb.CapabilityRequest{}) if err != nil { return nil, fmt.Errorf("gnmiext: failed to retrieve capabilities: %w", err) } - encoding := gpb.Encoding(-1) + encoding := gnmipb.Encoding(-1) for _, e := range res.GetSupportedEncodings() { switch e { - case gpb.Encoding_JSON, gpb.Encoding_JSON_IETF: + case gnmipb.Encoding_JSON, gnmipb.Encoding_JSON_IETF: encoding = e default: // Ignore unsupported encodings. @@ -113,7 +172,7 @@ func New(ctx context.Context, conn grpc.ClientConnInterface, opts ...Option) (Cl } } logger := logr.FromSlogHandler(slog.Default().Handler()) - c := &client{gnmi, encoding, capabilities, logger} + c := &client{gnmi, encoding, capabilities, logger, ""} for _, opt := range opts { opt(c) } @@ -129,6 +188,13 @@ func WithLogger(logger logr.Logger) Option { } } +// WithTarget sets the device target label used in metrics. +func WithTarget(target string) Option { + return func(c *client) { + c.target = target + } +} + // ErrNil indicates that the value for a xpath is not defined. var ErrNil = errors.New("gnmiext: nil") @@ -140,60 +206,76 @@ func (c *client) Capabilities() *Capabilities { // GetConfig retrieves config and unmarshals it into the provided targets. // If some of the values for the given xpaths are not defined, [ErrNil] is returned. func (c *client) GetConfig(ctx context.Context, el ...DataElement) error { - return c.get(ctx, gpb.GetRequest_CONFIG, el...) + return c.get(ctx, gnmipb.GetRequest_CONFIG, el...) } // GetState retrieves state and unmarshals it into the provided targets. // If some of the values for the given xpaths are not defined, [ErrNil] is returned. func (c *client) GetState(ctx context.Context, el ...DataElement) error { - return c.get(ctx, gpb.GetRequest_STATE, el...) + return c.get(ctx, gnmipb.GetRequest_STATE, el...) } -// Update replaces the configuration for the given set of items.4c890d +// Update replaces the configuration for the given set of items. // If the current configuration equals the desired configuration, the operation is skipped. // For partial updates that merge changes, use [Client.Patch] instead. func (c *client) Update(ctx context.Context, el ...DataElement) error { - return c.set(ctx, false, el...) + r := new(gnmipb.SetRequest) + if err := c.set(ctx, r, false, el...); err != nil { + return err + } + return c.do(ctx, r) } // Patch merges the configuration for the given set of items. // If the current configuration equals the desired configuration, the operation is skipped. // For full replacement of configuration, use [Client.Update] instead. func (c *client) Patch(ctx context.Context, el ...DataElement) error { - return c.set(ctx, true, el...) + r := new(gnmipb.SetRequest) + if err := c.set(ctx, r, true, el...); err != nil { + return err + } + return c.do(ctx, r) } // Delete resets the configuration for the given set of items. // If an item implements [Defaultable], it's reset to default value. // Otherwise, the configuration is deleted. func (c *client) Delete(ctx context.Context, el ...DataElement) error { - if len(el) == 0 { - return nil + r := new(gnmipb.SetRequest) + if err := c.delete(ctx, r, el...); err != nil { + return err } - r := new(gpb.SetRequest) - for _, e := range el { - path, err := StringToStructuredPath(e.XPath()) - if err != nil { - return err - } - if d, ok := e.(Defaultable); ok { - d.Default() - b, err := c.Marshal(e) - if err != nil { - return err + return c.do(ctx, r) +} + +// Do executes the accumulated operations in the SetBuilder as one or +// more gNMI Set RPCs, respecting the configured limit. +func (c *client) Do(ctx context.Context, b *SetBuilder) error { + chunks := [][]operation{b.ops} + if b.limit > 0 { + chunks = slices.Collect(slices.Chunk(b.ops, b.limit)) + } + for _, chunk := range chunks { + r := new(gnmipb.SetRequest) + for _, op := range chunk { + switch op.mode { + case replace: + if err := c.set(ctx, r, false, op.el); err != nil { + return err + } + case update: + if err := c.set(ctx, r, true, op.el); err != nil { + return err + } + case del: + if err := c.delete(ctx, r, op.el); err != nil { + return err + } } - c.logger.V(1).Info("Resetting to default", "path", e.XPath(), "payload", string(b)) - r.Replace = append(r.Replace, &gpb.Update{ - Path: path, - Val: c.Encode(b), - }) - continue } - c.logger.V(1).Info("Deleting", "path", e.XPath()) - r.Delete = append(r.Delete, path) - } - if _, err := c.gnmi.Set(ctx, r); err != nil { - return fmt.Errorf("gnmiext: failed to perform set rpc: %w", err) + if err := c.do(ctx, r); err != nil { + return err + } } return nil } @@ -201,11 +283,11 @@ func (c *client) Delete(ctx context.Context, el ...DataElement) error { // get retrieves data of the specified type (CONFIG or STATE) and unmarshals it // into the provided targets. If some of the values for the given xpaths are not // defined, [ErrNil] is returned. -func (c *client) get(ctx context.Context, dt gpb.GetRequest_DataType, el ...DataElement) error { +func (c *client) get(ctx context.Context, dt gnmipb.GetRequest_DataType, el ...DataElement) error { if len(el) == 0 { return nil } - r := &gpb.GetRequest{ + r := &gnmipb.GetRequest{ Type: dt, Encoding: c.encoding, } @@ -216,10 +298,23 @@ func (c *client) get(ctx context.Context, dt gpb.GetRequest_DataType, el ...Data } r.Path = append(r.Path, path) } + op := "get_config" + if dt == gnmipb.GetRequest_STATE { + op = "get_state" + } + start := time.Now() res, err := c.gnmi.Get(ctx, r) if err != nil { + rpcDurationSeconds.WithLabelValues(c.target, "Get", "error").Observe(time.Since(start).Seconds()) + for _, e := range el { + operationsTotal.WithLabelValues(c.target, "Get", op, e.XPath(), "error").Inc() + } return fmt.Errorf("gnmiext: failed to perform get rpc: %w", err) } + rpcDurationSeconds.WithLabelValues(c.target, "Get", "success").Observe(time.Since(start).Seconds()) + for _, e := range el { + operationsTotal.WithLabelValues(c.target, "Get", op, e.XPath(), "success").Inc() + } // As per [gNMI spec] the response MUST contain one notification // for each path in the request. // @@ -244,7 +339,7 @@ func (c *client) get(ctx context.Context, dt gpb.GetRequest_DataType, el ...Data return err } // Some target devices (e.g., Cisco NX-OS) have an incorrect - // implementation of the [gNMI spec] and return an empty [gpb.TypedValue] + // implementation of the [gNMI spec] and return an empty [gnmipb.TypedValue] // instead of a NotFound status error when the requested path is // syntactically correct but does not exist on the device. // @@ -267,16 +362,15 @@ func (c *client) get(ctx context.Context, dt gpb.GetRequest_DataType, el ...Data return nil } -// set applies the provided configuration items. If patch is true, a -// partial update is performed by merging the changes into the existing -// configuration. Otherwise, a full replacement is done. -// If the current configuration equals the desired configuration, the operation -// is skipped. -func (c *client) set(ctx context.Context, patch bool, el ...DataElement) error { - if len(el) == 0 { - return nil +// set appends update or replace operations to the given SetRequest. +// If patch is true, elements are appended as gNMI update (merge); +// otherwise as gNMI replace (full replacement). +// Elements whose current configuration already matches are skipped. +func (c *client) set(ctx context.Context, r *gnmipb.SetRequest, patch bool, el ...DataElement) error { + op := "replace" + if patch { + op = "update" } - r := new(gpb.SetRequest) for _, e := range el { path, err := StringToStructuredPath(e.XPath()) if err != nil { @@ -291,6 +385,7 @@ func (c *client) set(ctx context.Context, patch bool, el ...DataElement) error { // This avoids unnecessary updates and potential disruptions. if err == nil && reflect.DeepEqual(e, got) { c.logger.V(2).Info("Configuration is already up-to-date", "path", e.XPath()) + operationsSkippedTotal.WithLabelValues(c.target, op, e.XPath()).Inc() continue } b, err := c.Marshal(e) @@ -298,7 +393,7 @@ func (c *client) set(ctx context.Context, patch bool, el ...DataElement) error { return err } c.logger.V(1).Info("Updating", "path", e.XPath(), "payload", string(b), "patch", patch) - u := &gpb.Update{ + u := &gnmipb.Update{ Path: path, Val: c.Encode(b), } @@ -308,11 +403,65 @@ func (c *client) set(ctx context.Context, patch bool, el ...DataElement) error { } r.Replace = append(r.Replace, u) } - if len(r.GetUpdate()) == 0 && len(r.GetReplace()) == 0 { - // All configurations are already up-to-date. + return nil +} + +// delete appends delete operations to the given SetRequest. +// If an element implements [Defaultable], a replace-with-default is +// appended instead of a path deletion. +func (c *client) delete(_ context.Context, r *gnmipb.SetRequest, el ...DataElement) error { + for _, e := range el { + path, err := StringToStructuredPath(e.XPath()) + if err != nil { + return err + } + if d, ok := e.(Defaultable); ok { + d.Default() + b, err := c.Marshal(e) + if err != nil { + return err + } + c.logger.V(1).Info("Resetting to default", "path", e.XPath(), "payload", string(b)) + r.Replace = append(r.Replace, &gnmipb.Update{ + Path: path, + Val: c.Encode(b), + }) + continue + } + c.logger.V(1).Info("Deleting", "path", e.XPath()) + r.Delete = append(r.Delete, path) + } + return nil +} + +// do fires the given SetRequest and records operation metrics. Returns nil if the request is empty. +func (c *client) do(ctx context.Context, r *gnmipb.SetRequest) error { + if len(r.GetUpdate()) == 0 && len(r.GetReplace()) == 0 && len(r.GetDelete()) == 0 { return nil } - if _, err := c.gnmi.Set(ctx, r); err != nil { + start := time.Now() + _, err := c.gnmi.Set(ctx, r) + st := "success" + if err != nil { + st = "error" + } + rpcDurationSeconds.WithLabelValues(c.target, "Set", st).Observe(time.Since(start).Seconds()) + for _, u := range r.GetReplace() { + if p, err := ygot.PathToString(u.GetPath()); err == nil { + operationsTotal.WithLabelValues(c.target, "Set", "replace", p, st).Inc() + } + } + for _, u := range r.GetUpdate() { + if p, err := ygot.PathToString(u.GetPath()); err == nil { + operationsTotal.WithLabelValues(c.target, "Set", "update", p, st).Inc() + } + } + for _, d := range r.GetDelete() { + if p, err := ygot.PathToString(d); err == nil { + operationsTotal.WithLabelValues(c.target, "Set", "delete", p, st).Inc() + } + } + if err != nil { return fmt.Errorf("gnmiext: failed to perform set rpc: %w", err) } return nil @@ -401,18 +550,18 @@ func (c *client) Unmarshal(b []byte, dst any) (err error) { return nil } -// Encode encodes the provided byte slice into a [gpb.TypedValue] using the client's encoding. -func (c *client) Encode(b []byte) *gpb.TypedValue { +// Encode encodes the provided byte slice into a [gnmipb.TypedValue] using the client's encoding. +func (c *client) Encode(b []byte) *gnmipb.TypedValue { switch c.encoding { - case gpb.Encoding_JSON: - return &gpb.TypedValue{ - Value: &gpb.TypedValue_JsonVal{ + case gnmipb.Encoding_JSON: + return &gnmipb.TypedValue{ + Value: &gnmipb.TypedValue_JsonVal{ JsonVal: b, }, } - case gpb.Encoding_JSON_IETF: - return &gpb.TypedValue{ - Value: &gpb.TypedValue_JsonIetfVal{ + case gnmipb.Encoding_JSON_IETF: + return &gnmipb.TypedValue{ + Value: &gnmipb.TypedValue_JsonIetfVal{ JsonIetfVal: b, }, } @@ -421,17 +570,17 @@ func (c *client) Encode(b []byte) *gpb.TypedValue { } } -// Decode decodes the provided [gpb.TypedValue] into the provided destination using the client's encoding. -func (c *client) Decode(val *gpb.TypedValue) ([]byte, error) { +// Decode decodes the provided [gnmipb.TypedValue] into the provided destination using the client's encoding. +func (c *client) Decode(val *gnmipb.TypedValue) ([]byte, error) { switch c.encoding { - case gpb.Encoding_JSON: - v, ok := val.GetValue().(*gpb.TypedValue_JsonVal) + case gnmipb.Encoding_JSON: + v, ok := val.GetValue().(*gnmipb.TypedValue_JsonVal) if !ok { return nil, fmt.Errorf("gnmiext: unexpected value type: expected JsonVal, got %T", val.GetValue()) } return v.JsonVal, nil - case gpb.Encoding_JSON_IETF: - v, ok := val.GetValue().(*gpb.TypedValue_JsonIetfVal) + case gnmipb.Encoding_JSON_IETF: + v, ok := val.GetValue().(*gnmipb.TypedValue_JsonIetfVal) if !ok { return nil, fmt.Errorf("gnmiext: unexpected value type: expected JsonIetfVal, got %T", val.GetValue()) } @@ -448,7 +597,7 @@ func (c *client) Decode(val *gpb.TypedValue) ([]byte, error) { // the module name qualifies the first identifier in a JSON-encoded YANG path. // // [1]: https://datatracker.ietf.org/doc/html/rfc7951#section-4 -func StringToStructuredPath(xpath string) (*gpb.Path, error) { +func StringToStructuredPath(xpath string) (*gnmipb.Path, error) { path, err := ygot.StringToStructuredPath(xpath) if err != nil { return nil, fmt.Errorf("gnmiext: failed to convert xpath '%s' to path: %w", xpath, err) diff --git a/internal/transport/gnmiext/client_mock.go b/internal/transport/gnmiext/client_mock.go new file mode 100644 index 000000000..c7d3f2589 --- /dev/null +++ b/internal/transport/gnmiext/client_mock.go @@ -0,0 +1,444 @@ +// Code generated by moq; DO NOT EDIT. +// github.com/matryer/moq + +package gnmiext + +import ( + "context" + "sync" +) + +// Ensure, that ClientMock does implement Client. +// If this is not the case, regenerate this file with moq. +var _ Client = &ClientMock{} + +// ClientMock is a mock implementation of Client. +// +// func TestSomethingThatUsesClient(t *testing.T) { +// +// // make and configure a mocked Client +// mockedClient := &ClientMock{ +// CapabilitiesFunc: func() *Capabilities { +// panic("mock out the Capabilities method") +// }, +// DeleteFunc: func(contextMoqParam context.Context, dataElements ...DataElement) error { +// panic("mock out the Delete method") +// }, +// DoFunc: func(contextMoqParam context.Context, setBuilder *SetBuilder) error { +// panic("mock out the Do method") +// }, +// GetConfigFunc: func(contextMoqParam context.Context, dataElements ...DataElement) error { +// panic("mock out the GetConfig method") +// }, +// GetStateFunc: func(contextMoqParam context.Context, dataElements ...DataElement) error { +// panic("mock out the GetState method") +// }, +// PatchFunc: func(contextMoqParam context.Context, dataElements ...DataElement) error { +// panic("mock out the Patch method") +// }, +// UpdateFunc: func(contextMoqParam context.Context, dataElements ...DataElement) error { +// panic("mock out the Update method") +// }, +// } +// +// // use mockedClient in code that requires Client +// // and then make assertions. +// +// } +type ClientMock struct { + // CapabilitiesFunc mocks the Capabilities method. + CapabilitiesFunc func() *Capabilities + + // DeleteFunc mocks the Delete method. + DeleteFunc func(contextMoqParam context.Context, dataElements ...DataElement) error + + // DoFunc mocks the Do method. + DoFunc func(contextMoqParam context.Context, setBuilder *SetBuilder) error + + // GetConfigFunc mocks the GetConfig method. + GetConfigFunc func(contextMoqParam context.Context, dataElements ...DataElement) error + + // GetStateFunc mocks the GetState method. + GetStateFunc func(contextMoqParam context.Context, dataElements ...DataElement) error + + // PatchFunc mocks the Patch method. + PatchFunc func(contextMoqParam context.Context, dataElements ...DataElement) error + + // UpdateFunc mocks the Update method. + UpdateFunc func(contextMoqParam context.Context, dataElements ...DataElement) error + + // calls tracks calls to the methods. + calls struct { + // Capabilities holds details about calls to the Capabilities method. + Capabilities []struct{} + // Delete holds details about calls to the Delete method. + Delete []struct { + // ContextMoqParam is the contextMoqParam argument value. + ContextMoqParam context.Context + // DataElements is the dataElements argument value. + DataElements []DataElement + } + // Do holds details about calls to the Do method. + Do []struct { + // ContextMoqParam is the contextMoqParam argument value. + ContextMoqParam context.Context + // SetBuilder is the setBuilder argument value. + SetBuilder *SetBuilder + } + // GetConfig holds details about calls to the GetConfig method. + GetConfig []struct { + // ContextMoqParam is the contextMoqParam argument value. + ContextMoqParam context.Context + // DataElements is the dataElements argument value. + DataElements []DataElement + } + // GetState holds details about calls to the GetState method. + GetState []struct { + // ContextMoqParam is the contextMoqParam argument value. + ContextMoqParam context.Context + // DataElements is the dataElements argument value. + DataElements []DataElement + } + // Patch holds details about calls to the Patch method. + Patch []struct { + // ContextMoqParam is the contextMoqParam argument value. + ContextMoqParam context.Context + // DataElements is the dataElements argument value. + DataElements []DataElement + } + // Update holds details about calls to the Update method. + Update []struct { + // ContextMoqParam is the contextMoqParam argument value. + ContextMoqParam context.Context + // DataElements is the dataElements argument value. + DataElements []DataElement + } + } + lockCapabilities sync.RWMutex + lockDelete sync.RWMutex + lockDo sync.RWMutex + lockGetConfig sync.RWMutex + lockGetState sync.RWMutex + lockPatch sync.RWMutex + lockUpdate sync.RWMutex +} + +// Capabilities calls CapabilitiesFunc. +func (mock *ClientMock) Capabilities() *Capabilities { + if mock.CapabilitiesFunc == nil { + panic("ClientMock.CapabilitiesFunc: method is nil but Client.Capabilities was just called") + } + callInfo := struct{}{} + mock.lockCapabilities.Lock() + mock.calls.Capabilities = append(mock.calls.Capabilities, callInfo) + mock.lockCapabilities.Unlock() + return mock.CapabilitiesFunc() +} + +// CapabilitiesCalls gets all the calls that were made to Capabilities. +// Check the length with: +// +// len(mockedClient.CapabilitiesCalls()) +func (mock *ClientMock) CapabilitiesCalls() []struct{} { + var calls []struct{} + mock.lockCapabilities.RLock() + calls = mock.calls.Capabilities + mock.lockCapabilities.RUnlock() + return calls +} + +// ResetCapabilitiesCalls reset all the calls that were made to Capabilities. +func (mock *ClientMock) ResetCapabilitiesCalls() { + mock.lockCapabilities.Lock() + mock.calls.Capabilities = nil + mock.lockCapabilities.Unlock() +} + +// Delete calls DeleteFunc. +func (mock *ClientMock) Delete(contextMoqParam context.Context, dataElements ...DataElement) error { + if mock.DeleteFunc == nil { + panic("ClientMock.DeleteFunc: method is nil but Client.Delete was just called") + } + callInfo := struct { + ContextMoqParam context.Context + DataElements []DataElement + }{ + ContextMoqParam: contextMoqParam, + DataElements: dataElements, + } + mock.lockDelete.Lock() + mock.calls.Delete = append(mock.calls.Delete, callInfo) + mock.lockDelete.Unlock() + return mock.DeleteFunc(contextMoqParam, dataElements...) +} + +// DeleteCalls gets all the calls that were made to Delete. +// Check the length with: +// +// len(mockedClient.DeleteCalls()) +func (mock *ClientMock) DeleteCalls() []struct { + ContextMoqParam context.Context + DataElements []DataElement +} { + var calls []struct { + ContextMoqParam context.Context + DataElements []DataElement + } + mock.lockDelete.RLock() + calls = mock.calls.Delete + mock.lockDelete.RUnlock() + return calls +} + +// ResetDeleteCalls reset all the calls that were made to Delete. +func (mock *ClientMock) ResetDeleteCalls() { + mock.lockDelete.Lock() + mock.calls.Delete = nil + mock.lockDelete.Unlock() +} + +// Do calls DoFunc. +func (mock *ClientMock) Do(contextMoqParam context.Context, setBuilder *SetBuilder) error { + if mock.DoFunc == nil { + panic("ClientMock.DoFunc: method is nil but Client.Do was just called") + } + callInfo := struct { + ContextMoqParam context.Context + SetBuilder *SetBuilder + }{ + ContextMoqParam: contextMoqParam, + SetBuilder: setBuilder, + } + mock.lockDo.Lock() + mock.calls.Do = append(mock.calls.Do, callInfo) + mock.lockDo.Unlock() + return mock.DoFunc(contextMoqParam, setBuilder) +} + +// DoCalls gets all the calls that were made to Do. +// Check the length with: +// +// len(mockedClient.DoCalls()) +func (mock *ClientMock) DoCalls() []struct { + ContextMoqParam context.Context + SetBuilder *SetBuilder +} { + var calls []struct { + ContextMoqParam context.Context + SetBuilder *SetBuilder + } + mock.lockDo.RLock() + calls = mock.calls.Do + mock.lockDo.RUnlock() + return calls +} + +// ResetDoCalls reset all the calls that were made to Do. +func (mock *ClientMock) ResetDoCalls() { + mock.lockDo.Lock() + mock.calls.Do = nil + mock.lockDo.Unlock() +} + +// GetConfig calls GetConfigFunc. +func (mock *ClientMock) GetConfig(contextMoqParam context.Context, dataElements ...DataElement) error { + if mock.GetConfigFunc == nil { + panic("ClientMock.GetConfigFunc: method is nil but Client.GetConfig was just called") + } + callInfo := struct { + ContextMoqParam context.Context + DataElements []DataElement + }{ + ContextMoqParam: contextMoqParam, + DataElements: dataElements, + } + mock.lockGetConfig.Lock() + mock.calls.GetConfig = append(mock.calls.GetConfig, callInfo) + mock.lockGetConfig.Unlock() + return mock.GetConfigFunc(contextMoqParam, dataElements...) +} + +// GetConfigCalls gets all the calls that were made to GetConfig. +// Check the length with: +// +// len(mockedClient.GetConfigCalls()) +func (mock *ClientMock) GetConfigCalls() []struct { + ContextMoqParam context.Context + DataElements []DataElement +} { + var calls []struct { + ContextMoqParam context.Context + DataElements []DataElement + } + mock.lockGetConfig.RLock() + calls = mock.calls.GetConfig + mock.lockGetConfig.RUnlock() + return calls +} + +// ResetGetConfigCalls reset all the calls that were made to GetConfig. +func (mock *ClientMock) ResetGetConfigCalls() { + mock.lockGetConfig.Lock() + mock.calls.GetConfig = nil + mock.lockGetConfig.Unlock() +} + +// GetState calls GetStateFunc. +func (mock *ClientMock) GetState(contextMoqParam context.Context, dataElements ...DataElement) error { + if mock.GetStateFunc == nil { + panic("ClientMock.GetStateFunc: method is nil but Client.GetState was just called") + } + callInfo := struct { + ContextMoqParam context.Context + DataElements []DataElement + }{ + ContextMoqParam: contextMoqParam, + DataElements: dataElements, + } + mock.lockGetState.Lock() + mock.calls.GetState = append(mock.calls.GetState, callInfo) + mock.lockGetState.Unlock() + return mock.GetStateFunc(contextMoqParam, dataElements...) +} + +// GetStateCalls gets all the calls that were made to GetState. +// Check the length with: +// +// len(mockedClient.GetStateCalls()) +func (mock *ClientMock) GetStateCalls() []struct { + ContextMoqParam context.Context + DataElements []DataElement +} { + var calls []struct { + ContextMoqParam context.Context + DataElements []DataElement + } + mock.lockGetState.RLock() + calls = mock.calls.GetState + mock.lockGetState.RUnlock() + return calls +} + +// ResetGetStateCalls reset all the calls that were made to GetState. +func (mock *ClientMock) ResetGetStateCalls() { + mock.lockGetState.Lock() + mock.calls.GetState = nil + mock.lockGetState.Unlock() +} + +// Patch calls PatchFunc. +func (mock *ClientMock) Patch(contextMoqParam context.Context, dataElements ...DataElement) error { + if mock.PatchFunc == nil { + panic("ClientMock.PatchFunc: method is nil but Client.Patch was just called") + } + callInfo := struct { + ContextMoqParam context.Context + DataElements []DataElement + }{ + ContextMoqParam: contextMoqParam, + DataElements: dataElements, + } + mock.lockPatch.Lock() + mock.calls.Patch = append(mock.calls.Patch, callInfo) + mock.lockPatch.Unlock() + return mock.PatchFunc(contextMoqParam, dataElements...) +} + +// PatchCalls gets all the calls that were made to Patch. +// Check the length with: +// +// len(mockedClient.PatchCalls()) +func (mock *ClientMock) PatchCalls() []struct { + ContextMoqParam context.Context + DataElements []DataElement +} { + var calls []struct { + ContextMoqParam context.Context + DataElements []DataElement + } + mock.lockPatch.RLock() + calls = mock.calls.Patch + mock.lockPatch.RUnlock() + return calls +} + +// ResetPatchCalls reset all the calls that were made to Patch. +func (mock *ClientMock) ResetPatchCalls() { + mock.lockPatch.Lock() + mock.calls.Patch = nil + mock.lockPatch.Unlock() +} + +// Update calls UpdateFunc. +func (mock *ClientMock) Update(contextMoqParam context.Context, dataElements ...DataElement) error { + if mock.UpdateFunc == nil { + panic("ClientMock.UpdateFunc: method is nil but Client.Update was just called") + } + callInfo := struct { + ContextMoqParam context.Context + DataElements []DataElement + }{ + ContextMoqParam: contextMoqParam, + DataElements: dataElements, + } + mock.lockUpdate.Lock() + mock.calls.Update = append(mock.calls.Update, callInfo) + mock.lockUpdate.Unlock() + return mock.UpdateFunc(contextMoqParam, dataElements...) +} + +// UpdateCalls gets all the calls that were made to Update. +// Check the length with: +// +// len(mockedClient.UpdateCalls()) +func (mock *ClientMock) UpdateCalls() []struct { + ContextMoqParam context.Context + DataElements []DataElement +} { + var calls []struct { + ContextMoqParam context.Context + DataElements []DataElement + } + mock.lockUpdate.RLock() + calls = mock.calls.Update + mock.lockUpdate.RUnlock() + return calls +} + +// ResetUpdateCalls reset all the calls that were made to Update. +func (mock *ClientMock) ResetUpdateCalls() { + mock.lockUpdate.Lock() + mock.calls.Update = nil + mock.lockUpdate.Unlock() +} + +// ResetCalls reset all the calls that were made to all mocked methods. +func (mock *ClientMock) ResetCalls() { + mock.lockCapabilities.Lock() + mock.calls.Capabilities = nil + mock.lockCapabilities.Unlock() + + mock.lockDelete.Lock() + mock.calls.Delete = nil + mock.lockDelete.Unlock() + + mock.lockDo.Lock() + mock.calls.Do = nil + mock.lockDo.Unlock() + + mock.lockGetConfig.Lock() + mock.calls.GetConfig = nil + mock.lockGetConfig.Unlock() + + mock.lockGetState.Lock() + mock.calls.GetState = nil + mock.lockGetState.Unlock() + + mock.lockPatch.Lock() + mock.calls.Patch = nil + mock.lockPatch.Unlock() + + mock.lockUpdate.Lock() + mock.calls.Update = nil + mock.lockUpdate.Unlock() +} diff --git a/internal/transport/gnmiext/client_test.go b/internal/transport/gnmiext/client_test.go index 34f1a8579..fb99af027 100644 --- a/internal/transport/gnmiext/client_test.go +++ b/internal/transport/gnmiext/client_test.go @@ -1112,6 +1112,167 @@ func TestClient_Delete(t *testing.T) { } } +func TestClient_Do(t *testing.T) { + tests := []struct { + name string + conn grpc.ClientConnInterface + builder *SetBuilder + wantErr bool + }{ + { + name: "Mixed operations in single request", + conn: &MockClientConn{ + GetFunc: func(ctx context.Context, req *gpb.GetRequest) (*gpb.GetResponse, error) { + return &gpb.GetResponse{ + Notification: []*gpb.Notification{ + { + Update: []*gpb.Update{ + { + Path: req.Path[0], + Val: &gpb.TypedValue{ + Value: &gpb.TypedValue_JsonVal{ + JsonVal: []byte(`"old-value"`), + }, + }, + }, + }, + }, + }, + }, nil + }, + SetFunc: func(ctx context.Context, req *gpb.SetRequest) (*gpb.SetResponse, error) { + if len(req.Replace) != 1 { + t.Errorf("Expected 1 Replace, got %d", len(req.Replace)) + } + if len(req.Update) != 1 { + t.Errorf("Expected 1 Update, got %d", len(req.Update)) + } + if len(req.Delete) != 1 { + t.Errorf("Expected 1 Delete, got %d", len(req.Delete)) + } + return &gpb.SetResponse{ + Timestamp: time.Now().UnixNano(), + }, nil + }, + }, + builder: new(SetBuilder). + Update(new(Hostname("update-host"))). + Patch(new(Hostname("patch-host"))). + Delete(new(Hostname)), + wantErr: false, + }, + { + name: "All unchanged skips Set RPC", + conn: &MockClientConn{ + GetFunc: func(ctx context.Context, req *gpb.GetRequest) (*gpb.GetResponse, error) { + return &gpb.GetResponse{ + Notification: []*gpb.Notification{ + { + Update: []*gpb.Update{ + { + Path: req.Path[0], + Val: &gpb.TypedValue{ + Value: &gpb.TypedValue_JsonVal{ + JsonVal: []byte(`"same"`), + }, + }, + }, + }, + }, + }, + }, nil + }, + SetFunc: func(ctx context.Context, req *gpb.SetRequest) (*gpb.SetResponse, error) { + t.Error("Set RPC should not be called when config is unchanged") + return nil, nil //nolint:nilnil + }, + }, + builder: new(SetBuilder). + Update(new(Hostname("same"))), + wantErr: false, + }, + { + name: "Limit splits into multiple RPCs", + conn: func() *MockClientConn { + calls := 0 + return &MockClientConn{ + GetFunc: func(ctx context.Context, req *gpb.GetRequest) (*gpb.GetResponse, error) { + return nil, status.Error(codes.NotFound, "not found") + }, + SetFunc: func(ctx context.Context, req *gpb.SetRequest) (*gpb.SetResponse, error) { + calls++ + total := len(req.Replace) + len(req.Update) + len(req.Delete) + if total > 2 { + t.Errorf("Set call %d: expected at most 2 operations, got %d", calls, total) + } + return &gpb.SetResponse{ + Timestamp: time.Now().UnixNano(), + }, nil + }, + } + }(), + builder: new(SetBuilder). + Limit(2). + Update(new(Hostname("a"))). + Update(new(Hostname("b"))). + Update(new(Hostname("c"))), + wantErr: false, + }, + { + name: "Empty builder", + builder: new(SetBuilder), + wantErr: false, + }, + { + name: "Set RPC error", + conn: &MockClientConn{ + GetFunc: func(ctx context.Context, req *gpb.GetRequest) (*gpb.GetResponse, error) { + return nil, status.Error(codes.NotFound, "not found") + }, + SetFunc: func(ctx context.Context, req *gpb.SetRequest) (*gpb.SetResponse, error) { + return nil, errors.New("set rpc failed") + }, + }, + builder: new(SetBuilder). + Update(new(Hostname("new"))), + wantErr: true, + }, + { + name: "Defaultable delete becomes replace", + conn: &MockClientConn{ + SetFunc: func(ctx context.Context, req *gpb.SetRequest) (*gpb.SetResponse, error) { + if len(req.Replace) != 1 { + t.Errorf("Expected 1 Replace for Defaultable, got %d", len(req.Replace)) + } + if len(req.Delete) != 0 { + t.Errorf("Expected 0 Delete for Defaultable, got %d", len(req.Delete)) + } + return &gpb.SetResponse{ + Timestamp: time.Now().UnixNano(), + }, nil + }, + }, + builder: new(SetBuilder). + Delete(new(DefaultableHostname)), + wantErr: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + client := &client{ + encoding: gpb.Encoding_JSON, + gnmi: gpb.NewGNMIClient(test.conn), + } + + err := client.Do(t.Context(), test.builder) + if (err != nil) != test.wantErr { + t.Errorf("Do() error = %v, wantErr %v", err, test.wantErr) + } + }) + } +} + func TestStringToStructuredPath(t *testing.T) { tests := []struct { name string diff --git a/internal/transport/gnmiext/metrics.go b/internal/transport/gnmiext/metrics.go new file mode 100644 index 000000000..8ba043b7b --- /dev/null +++ b/internal/transport/gnmiext/metrics.go @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package gnmiext + +import ( + "github.com/prometheus/client_golang/prometheus" + "sigs.k8s.io/controller-runtime/pkg/metrics" +) + +var ( + operationsTotal = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "gnmi_operations_total", + Help: "Total number of gNMI operations performed.", + }, + []string{"target", "rpc", "operation", "path", "status"}, + ) + rpcDurationSeconds = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "gnmi_rpc_duration_seconds", + Help: "Duration of gNMI RPCs in seconds.", + Buckets: prometheus.DefBuckets, + }, + []string{"target", "rpc", "status"}, + ) + operationsSkippedTotal = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "gnmi_operations_skipped_total", + Help: "Total number of gNMI operations skipped due to config being unchanged.", + }, + []string{"target", "operation", "path"}, + ) +) + +func init() { + metrics.Registry.MustRegister( + operationsTotal, + rpcDurationSeconds, + operationsSkippedTotal, + ) +}