Skip to content

Commit 50ae38f

Browse files
feat: Introduce Client as an interface
Refactor the Provider to depend on a Client interface instead of a concrete implementation. This simplifies testing and mocking by allowing the Client itself to be mocked, rather than individual gRPC calls.
1 parent d9314dc commit 50ae38f

File tree

5 files changed

+193
-25
lines changed

5 files changed

+193
-25
lines changed

internal/provider/cisco/gnmiext/v2/client.go

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -55,21 +55,33 @@ type Capabilities struct {
5555
SupportedModels []Model
5656
}
5757

58+
type Client interface {
59+
GetState(ctx context.Context, conf ...Configurable) error
60+
GetConfig(ctx context.Context, conf ...Configurable) error
61+
Patch(ctx context.Context, conf ...Configurable) error
62+
Update(ctx context.Context, conf ...Configurable) error
63+
Delete(ctx context.Context, conf ...Configurable) error
64+
}
65+
5866
// Client is a gNMI client offering convenience methods for device configuration
5967
// using gNMI.
60-
type Client struct {
68+
type ClientObj struct {
6169
gnmi gpb.GNMIClient
6270
encoding gpb.Encoding
6371
capabilities *Capabilities
6472
logger logr.Logger
6573
}
6674

75+
var (
76+
_ Client = &ClientObj{}
77+
)
78+
6779
// New creates a new Client by negotiating capabilities with the gNMI server by
6880
// carrying out a Capabilities RPC.
6981
// Returns an error if the device doesn't support JSON encoding.
7082
// By default, the client uses [slog.Default] for logging.
7183
// Use [WithLogger] to provide a custom logger.
72-
func New(ctx context.Context, conn grpc.ClientConnInterface, opts ...Option) (*Client, error) {
84+
func New(ctx context.Context, conn grpc.ClientConnInterface, opts ...Option) (Client, error) {
7385
gnmi := gpb.NewGNMIClient(conn)
7486
res, err := gnmi.Capabilities(ctx, &gpb.CapabilityRequest{})
7587
if err != nil {
@@ -96,18 +108,18 @@ func New(ctx context.Context, conn grpc.ClientConnInterface, opts ...Option) (*C
96108
}
97109
}
98110
logger := logr.FromSlogHandler(slog.Default().Handler())
99-
client := &Client{gnmi, encoding, capabilities, logger}
111+
client := &ClientObj{gnmi, encoding, capabilities, logger}
100112
for _, opt := range opts {
101113
opt(client)
102114
}
103115
return client, nil
104116
}
105117

106-
type Option func(*Client)
118+
type Option func(*ClientObj)
107119

108120
// WithLogger sets a custom logger for the client.
109121
func WithLogger(logger logr.Logger) Option {
110-
return func(c *Client) {
122+
return func(c *ClientObj) {
111123
c.logger = logger
112124
}
113125
}
@@ -117,34 +129,34 @@ var ErrNil = errors.New("gnmiext: nil")
117129

118130
// GetConfig retrieves config and unmarshals it into the provided targets.
119131
// If some of the values for the given xpaths are not defined, [ErrNil] is returned.
120-
func (c *Client) GetConfig(ctx context.Context, conf ...Configurable) error {
132+
func (c *ClientObj) GetConfig(ctx context.Context, conf ...Configurable) error {
121133
return c.get(ctx, gpb.GetRequest_CONFIG, conf...)
122134
}
123135

124136
// GetState retrieves state and unmarshals it into the provided targets.
125137
// If some of the values for the given xpaths are not defined, [ErrNil] is returned.
126-
func (c *Client) GetState(ctx context.Context, conf ...Configurable) error {
138+
func (c *ClientObj) GetState(ctx context.Context, conf ...Configurable) error {
127139
return c.get(ctx, gpb.GetRequest_STATE, conf...)
128140
}
129141

130142
// Update replaces the configuration for the given set of items.
131143
// If the current configuration equals the desired configuration, the operation is skipped.
132144
// For partial updates that merge changes, use [Client.Patch] instead.
133-
func (c *Client) Update(ctx context.Context, conf ...Configurable) error {
145+
func (c *ClientObj) Update(ctx context.Context, conf ...Configurable) error {
134146
return c.set(ctx, false, conf...)
135147
}
136148

137149
// Patch merges the configuration for the given set of items.
138150
// If the current configuration equals the desired configuration, the operation is skipped.
139151
// For full replacement of configuration, use [Client.Update] instead.
140-
func (c *Client) Patch(ctx context.Context, conf ...Configurable) error {
152+
func (c *ClientObj) Patch(ctx context.Context, conf ...Configurable) error {
141153
return c.set(ctx, true, conf...)
142154
}
143155

144156
// Delete resets the configuration for the given set of items.
145157
// If an item implements [Defaultable], it's reset to default value.
146158
// Otherwise, the configuration is deleted.
147-
func (c *Client) Delete(ctx context.Context, conf ...Configurable) error {
159+
func (c *ClientObj) Delete(ctx context.Context, conf ...Configurable) error {
148160
if len(conf) == 0 {
149161
return nil
150162
}
@@ -179,7 +191,7 @@ func (c *Client) Delete(ctx context.Context, conf ...Configurable) error {
179191
// get retrieves data of the specified type (CONFIG or STATE) and unmarshals it
180192
// into the provided targets. If some of the values for the given xpaths are not
181193
// defined, [ErrNil] is returned.
182-
func (c *Client) get(ctx context.Context, dt gpb.GetRequest_DataType, conf ...Configurable) error {
194+
func (c *ClientObj) get(ctx context.Context, dt gpb.GetRequest_DataType, conf ...Configurable) error {
183195
if len(conf) == 0 {
184196
return nil
185197
}
@@ -244,7 +256,7 @@ func (c *Client) get(ctx context.Context, dt gpb.GetRequest_DataType, conf ...Co
244256
// configuration. Otherwise, a full replacement is done.
245257
// If the current configuration equals the desired configuration, the operation
246258
// is skipped.
247-
func (c *Client) set(ctx context.Context, patch bool, conf ...Configurable) error {
259+
func (c *ClientObj) set(ctx context.Context, patch bool, conf ...Configurable) error {
248260
if len(conf) == 0 {
249261
return nil
250262
}
@@ -293,7 +305,7 @@ func (c *Client) set(ctx context.Context, patch bool, conf ...Configurable) erro
293305
// Marshal marshals the provided value into a byte slice using the client's encoding.
294306
// If the value implements the [Marshaler] interface, it will be marshaled using that.
295307
// Otherwise, [json.Marshal] is used.
296-
func (c *Client) Marshal(v any) (b []byte, err error) {
308+
func (c *ClientObj) Marshal(v any) (b []byte, err error) {
297309
if m, ok := v.(Marshaler); ok {
298310
b, err = m.MarshalYANG(c.capabilities)
299311
if err != nil {
@@ -311,7 +323,7 @@ func (c *Client) Marshal(v any) (b []byte, err error) {
311323
// Unmarshal unmarshals the provided byte slice into the provided destination.
312324
// If the destination implements the [Marshaler] interface, it will be unmarshaled using that.
313325
// Otherwise, [json.Unmarshal] is used.
314-
func (c *Client) Unmarshal(b []byte, dst any) (err error) {
326+
func (c *ClientObj) Unmarshal(b []byte, dst any) (err error) {
315327
// NOTE: If you query for list elements on Cisco NX-OS, the encoded payload
316328
// will be the wrapped in an array (even if only one element is requested), i.e.
317329
//
@@ -339,7 +351,7 @@ func (c *Client) Unmarshal(b []byte, dst any) (err error) {
339351
}
340352

341353
// Encode encodes the provided byte slice into a [gpb.TypedValue] using the client's encoding.
342-
func (c *Client) Encode(b []byte) *gpb.TypedValue {
354+
func (c *ClientObj) Encode(b []byte) *gpb.TypedValue {
343355
switch c.encoding {
344356
case gpb.Encoding_JSON:
345357
return &gpb.TypedValue{
@@ -359,7 +371,7 @@ func (c *Client) Encode(b []byte) *gpb.TypedValue {
359371
}
360372

361373
// Decode decodes the provided [gpb.TypedValue] into the provided destination using the client's encoding.
362-
func (c *Client) Decode(val *gpb.TypedValue) ([]byte, error) {
374+
func (c *ClientObj) Decode(val *gpb.TypedValue) ([]byte, error) {
363375
switch c.encoding {
364376
case gpb.Encoding_JSON:
365377
v, ok := val.Value.(*gpb.TypedValue_JsonVal)

internal/provider/cisco/gnmiext/v2/client_test.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -502,7 +502,7 @@ func TestClient_GetConfig(t *testing.T) {
502502

503503
for _, test := range tests {
504504
t.Run(test.name, func(t *testing.T) {
505-
client := &Client{
505+
client := &ClientObj{
506506
encoding: gpb.Encoding_JSON,
507507
gnmi: gpb.NewGNMIClient(test.conn),
508508
}
@@ -582,7 +582,7 @@ func TestClient_GetState(t *testing.T) {
582582

583583
for _, test := range tests {
584584
t.Run(test.name, func(t *testing.T) {
585-
client := &Client{
585+
client := &ClientObj{
586586
encoding: gpb.Encoding_JSON,
587587
gnmi: gpb.NewGNMIClient(test.conn),
588588
}
@@ -853,7 +853,7 @@ func TestClient_Update(t *testing.T) {
853853

854854
for _, test := range tests {
855855
t.Run(test.name, func(t *testing.T) {
856-
client := &Client{
856+
client := &ClientObj{
857857
encoding: gpb.Encoding_JSON,
858858
gnmi: gpb.NewGNMIClient(test.conn),
859859
}
@@ -1015,7 +1015,7 @@ func TestClient_Patch(t *testing.T) {
10151015

10161016
for _, test := range tests {
10171017
t.Run(test.name, func(t *testing.T) {
1018-
client := &Client{
1018+
client := &ClientObj{
10191019
encoding: gpb.Encoding_JSON_IETF,
10201020
gnmi: gpb.NewGNMIClient(test.conn),
10211021
}
@@ -1133,7 +1133,7 @@ func TestClient_Delete(t *testing.T) {
11331133

11341134
for _, test := range tests {
11351135
t.Run(test.name, func(t *testing.T) {
1136-
client := &Client{
1136+
client := &ClientObj{
11371137
encoding: gpb.Encoding_JSON,
11381138
gnmi: gpb.NewGNMIClient(test.conn),
11391139
}
@@ -1231,7 +1231,7 @@ func TestClient_Marshal(t *testing.T) {
12311231

12321232
for _, test := range tests {
12331233
t.Run(test.name, func(t *testing.T) {
1234-
client := &Client{
1234+
client := &ClientObj{
12351235
capabilities: &Capabilities{
12361236
SupportedModels: []Model{
12371237
{Name: "openconfig-interfaces", Organization: "OpenConfig working group", Version: "2.5.0"},
@@ -1288,7 +1288,7 @@ func TestClient_Unmarshal(t *testing.T) {
12881288

12891289
for _, test := range tests {
12901290
t.Run(test.name, func(t *testing.T) {
1291-
client := &Client{
1291+
client := &ClientObj{
12921292
capabilities: &Capabilities{
12931293
SupportedModels: []Model{
12941294
{Name: "openconfig-interfaces", Organization: "OpenConfig working group", Version: "2.5.0"},
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
package iosxr
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/ironcore-dev/network-operator/internal/deviceutil"
8+
"github.com/ironcore-dev/network-operator/internal/provider"
9+
"github.com/ironcore-dev/network-operator/internal/provider/cisco/gnmiext/v2"
10+
"google.golang.org/grpc"
11+
)
12+
13+
var (
14+
_ provider.Provider = &Provider{}
15+
_ provider.InterfaceProvider = &Provider{}
16+
)
17+
18+
type Provider struct {
19+
conn *grpc.ClientConn
20+
client gnmiext.Client
21+
}
22+
23+
func NewProvider() provider.Provider {
24+
return &Provider{}
25+
}
26+
27+
func (p *Provider) Connect(ctx context.Context, conn *deviceutil.Connection) (err error) {
28+
p.conn, err = deviceutil.NewGrpcClient(ctx, conn)
29+
if err != nil {
30+
return fmt.Errorf("failed to create grpc connection: %w", err)
31+
}
32+
p.client, err = gnmiext.New(ctx, p.conn)
33+
if err != nil {
34+
return err
35+
}
36+
fmt.Println("Connected to IOSXR device:", conn.Address)
37+
return nil
38+
}
39+
40+
func (p *Provider) Disconnect(ctx context.Context, conn *deviceutil.Connection) error {
41+
return p.conn.Close()
42+
}
43+
44+
func (p *Provider) EnsureInterface(ctx context.Context, req *provider.InterfaceRequest) error {
45+
if p.client == nil {
46+
return fmt.Errorf("client is not connected")
47+
}
48+
var name string = req.Interface.Spec.Name
49+
50+
var physif *PhisIf = NewIface(name)
51+
52+
physif.Name = req.Interface.Spec.Name
53+
physif.Description = req.Interface.Spec.Description
54+
55+
physif.Statistics.LoadInterval = 30
56+
owner, err := ExractMTUOwnerFromIfaceName(name)
57+
if err != nil {
58+
return fmt.Errorf("failed to extract MTU owner from interface name %s: %w", name, err)
59+
}
60+
physif.MTUs = MTUs{MTU: []MTU{{MTU: uint16(req.Interface.Spec.MTU), Owner: string(owner)}}}
61+
62+
// (fixme): for the moment it is enought to keep this static
63+
// option1: extend existing interface spec
64+
// option2: create a custom iosxr config
65+
physif.Shutdown = Empty(false)
66+
physif.Statistics.LoadInterval = uint8(30)
67+
68+
if len(req.Interface.Spec.IPv4.Addresses) == 0 {
69+
return fmt.Errorf("no IPv4 address configured for interface %s", name)
70+
}
71+
72+
if len(req.Interface.Spec.IPv4.Addresses) > 1 {
73+
return fmt.Errorf("only a single primary IPv4 address is supported for interface %s", name)
74+
}
75+
76+
// (fixme): support IPv6 addresses, IPv6 neighbor config
77+
ip := req.Interface.Spec.IPv4.Addresses[0].Prefix.Addr().String()
78+
ipNet := req.Interface.Spec.IPv4.Addresses[0].Prefix.Bits()
79+
if err != nil {
80+
return fmt.Errorf("failed to parse IPv4 address %s: %w", req.Interface.Spec.IPv4.Addresses[0], err)
81+
}
82+
83+
physif.IPv4Network = IPv4Network{
84+
Addresses: AddressesIPv4{
85+
Primary: Primary{
86+
Address: ip,
87+
Netmask: string(ipNet),
88+
},
89+
},
90+
}
91+
92+
// Check if interface exists otherwise patch will fail
93+
var tmpiFace *PhisIf = NewIface(name)
94+
err = p.client.GetConfig(ctx, tmpiFace)
95+
if err != nil {
96+
// Interface does not exist, create it
97+
err = p.client.Update(ctx, physif)
98+
if err != nil {
99+
return fmt.Errorf("failed to create interface %s: %w", req.Interface.Spec.Name, err)
100+
}
101+
fmt.Printf("Interface %s created successfully\n", req.Interface.Spec.Name)
102+
return nil
103+
}
104+
105+
err = p.client.Patch(ctx, physif)
106+
if err != nil {
107+
return err
108+
}
109+
110+
return nil
111+
}
112+
113+
func (p *Provider) DeleteInterface(ctx context.Context, req *provider.InterfaceRequest) error {
114+
var iFace = NewIface(req.Interface.Spec.Name)
115+
116+
if p.client == nil {
117+
return fmt.Errorf("client is not connected")
118+
}
119+
120+
err := p.client.Delete(ctx, iFace)
121+
if err != nil {
122+
return fmt.Errorf("failed to delete interface %s: %w", req.Interface.Spec.Name, err)
123+
}
124+
return nil
125+
}
126+
127+
func (p *Provider) GetInterfaceStatus(ctx context.Context, req *provider.InterfaceRequest) (provider.InterfaceStatus, error) {
128+
state := new(PhysIfState)
129+
state.Name = req.Interface.Spec.Name
130+
131+
if p.client == nil {
132+
return provider.InterfaceStatus{}, fmt.Errorf("client is not connected")
133+
}
134+
135+
states, err := p.client.GetStateWithMultipleUpdates(ctx, state)
136+
137+
if err != nil {
138+
return provider.InterfaceStatus{}, fmt.Errorf("failed to get interface status for %s: %w", req.Interface.Spec.Name, err)
139+
}
140+
141+
providerStatus := provider.InterfaceStatus{
142+
OperStatus: true,
143+
}
144+
for _, s := range *states {
145+
currState := s.(*PhysIfState)
146+
if stateMapping[currState.State] != StateUp {
147+
providerStatus.OperStatus = false
148+
break
149+
}
150+
}
151+
return providerStatus, nil
152+
}
153+
154+
func init() {
155+
provider.Register("cisco-iosxr-gnmi", NewProvider)
156+
}

internal/provider/cisco/nxos/intf.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,7 @@ func Range(r []int32) string {
258258
}
259259

260260
// Exists checks if all provided interface names exist on the device.
261-
func Exists(ctx context.Context, client *gnmiext.Client, names ...string) (bool, error) {
261+
func Exists(ctx context.Context, client gnmiext.Client, names ...string) (bool, error) {
262262
if len(names) == 0 {
263263
return false, errors.New("at least one interface name must be provided")
264264
}

internal/provider/cisco/nxos/provider.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ var (
4747

4848
type Provider struct {
4949
conn *grpc.ClientConn
50-
client *gnmiext.Client
50+
client gnmiext.Client
5151
}
5252

5353
func NewProvider() provider.Provider {

0 commit comments

Comments
 (0)