From cb9a926e5cce283df39a2fcc73d31cdb7f514d06 Mon Sep 17 00:00:00 2001 From: Sven Rosenzweig Date: Fri, 31 Oct 2025 13:29:08 +0100 Subject: [PATCH 1/4] fix: Support unencrypted GRPC connections Some network devices do not have TLS configured on their side, requiring the use of insecure credentials. To support these cases, this change disables transport security enforcement when insecure credentials are used. Note: This option is intended for development and testing purposes only. --- internal/deviceutil/deviceutil.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/internal/deviceutil/deviceutil.go b/internal/deviceutil/deviceutil.go index 3c9dc07e..95a0486e 100644 --- a/internal/deviceutil/deviceutil.go +++ b/internal/deviceutil/deviceutil.go @@ -164,8 +164,9 @@ func WithDefaultTimeout(timeout time.Duration) Option { } type auth struct { - Username string - Password string + Username string + Password string + SecureTransportCreds bool } var _ credentials.PerRPCCredentials = (*auth)(nil) @@ -177,7 +178,10 @@ func (a *auth) GetRequestMetadata(_ context.Context, _ ...string) (map[string]st }, nil } -func (a *auth) RequireTransportSecurity() bool { return true } +func (a *auth) RequireTransportSecurity() bool { + // Only called if the transport credentials are insecure. + return false +} // UnaryDefaultTimeoutInterceptor returns a gRPC unary client interceptor that sets a default timeout // for each RPC. If a deadline is already present , it will not be modified. From bef7f68a3aa7f2fe874c21c3d2f1ac9d4bbbff4a Mon Sep 17 00:00:00 2001 From: Sven Rosenzweig Date: Thu, 13 Nov 2025 13:31:40 +0100 Subject: [PATCH 2/4] 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. --- internal/provider/cisco/gnmiext/v2/client.go | 54 +++--- .../provider/cisco/gnmiext/v2/client_test.go | 14 +- internal/provider/cisco/iosxr/provider.go | 155 ++++++++++++++++++ internal/provider/cisco/nxos/intf.go | 2 +- internal/provider/cisco/nxos/provider.go | 2 +- 5 files changed, 195 insertions(+), 32 deletions(-) create mode 100644 internal/provider/cisco/iosxr/provider.go diff --git a/internal/provider/cisco/gnmiext/v2/client.go b/internal/provider/cisco/gnmiext/v2/client.go index 9a661e2f..73fc2f8d 100644 --- a/internal/provider/cisco/gnmiext/v2/client.go +++ b/internal/provider/cisco/gnmiext/v2/client.go @@ -56,21 +56,33 @@ type Capabilities struct { SupportedModels []Model } +type Client interface { + GetState(ctx context.Context, conf ...Configurable) error + GetConfig(ctx context.Context, conf ...Configurable) error + Patch(ctx context.Context, conf ...Configurable) error + Update(ctx context.Context, conf ...Configurable) error + Delete(ctx context.Context, conf ...Configurable) error +} + // Client is a gNMI client offering convenience methods for device configuration // using gNMI. -type Client struct { +type client struct { gnmi gpb.GNMIClient encoding gpb.Encoding capabilities *Capabilities logger logr.Logger } +var ( + _ Client = &client{} +) + // New creates a new Client by negotiating capabilities with the gNMI server by // carrying out a Capabilities RPC. // Returns an error if the device doesn't support JSON encoding. // 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) { +func New(ctx context.Context, conn grpc.ClientConnInterface, opts ...Option) (Client, error) { gnmi := gpb.NewGNMIClient(conn) res, err := gnmi.Capabilities(ctx, &gpb.CapabilityRequest{}) if err != nil { @@ -97,18 +109,18 @@ func New(ctx context.Context, conn grpc.ClientConnInterface, opts ...Option) (*C } } logger := logr.FromSlogHandler(slog.Default().Handler()) - client := &Client{gnmi, encoding, capabilities, logger} + c := &client{gnmi, encoding, capabilities, logger} for _, opt := range opts { - opt(client) + opt(c) } - return client, nil + return c, nil } -type Option func(*Client) +type Option func(*client) // WithLogger sets a custom logger for the client. func WithLogger(logger logr.Logger) Option { - return func(c *Client) { + return func(c *client) { c.logger = logger } } @@ -118,36 +130,34 @@ var ErrNil = errors.New("gnmiext: nil") // 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. -// Fields that are not present in the response are set to their zero value. -func (c *Client) GetConfig(ctx context.Context, conf ...Configurable) error { +func (c *client) GetConfig(ctx context.Context, conf ...Configurable) error { return c.get(ctx, gpb.GetRequest_CONFIG, conf...) } // 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. -// Fields that are not present in the response are set to their zero value. -func (c *Client) GetState(ctx context.Context, conf ...Configurable) error { +func (c *client) GetState(ctx context.Context, conf ...Configurable) error { return c.get(ctx, gpb.GetRequest_STATE, conf...) } -// Update replaces the configuration for the given set of items. +// Update replaces the configuration for the given set of items.4c890d // 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, conf ...Configurable) error { +func (c *client) Update(ctx context.Context, conf ...Configurable) error { return c.set(ctx, false, conf...) } // 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, conf ...Configurable) error { +func (c *client) Patch(ctx context.Context, conf ...Configurable) error { return c.set(ctx, true, conf...) } // 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, conf ...Configurable) error { +func (c *client) Delete(ctx context.Context, conf ...Configurable) error { if len(conf) == 0 { return nil } @@ -182,7 +192,7 @@ func (c *Client) Delete(ctx context.Context, conf ...Configurable) 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, conf ...Configurable) error { +func (c *client) get(ctx context.Context, dt gpb.GetRequest_DataType, conf ...Configurable) error { if len(conf) == 0 { return nil } @@ -247,7 +257,7 @@ func (c *Client) get(ctx context.Context, dt gpb.GetRequest_DataType, conf ...Co // 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, conf ...Configurable) error { +func (c *client) set(ctx context.Context, patch bool, conf ...Configurable) error { if len(conf) == 0 { return nil } @@ -296,7 +306,7 @@ func (c *Client) set(ctx context.Context, patch bool, conf ...Configurable) erro // Marshal marshals the provided value into a byte slice using the client's encoding. // If the value implements the [Marshaler] interface, it will be marshaled using that. // Otherwise, [json.Marshal] is used. -func (c *Client) Marshal(v any) (b []byte, err error) { +func (c *client) Marshal(v any) (b []byte, err error) { if m, ok := v.(Marshaler); ok { b, err = m.MarshalYANG(c.capabilities) if err != nil { @@ -341,9 +351,7 @@ func zeroUnknownFields(b []byte, rv reflect.Value) { // Unmarshal unmarshals the provided byte slice into the provided destination. // If the destination implements the [Marshaler] interface, it will be unmarshaled using that. // Otherwise, [json.Unmarshal] is used. -// Additionally, if will ensure that fields not present in the JSON -// are set to their zero value. -func (c *Client) Unmarshal(b []byte, dst any) (err error) { +func (c *client) Unmarshal(b []byte, dst any) (err error) { // NOTE: If you query for list elements on Cisco NX-OS, the encoded payload // will be the wrapped in an array (even if only one element is requested), i.e. // @@ -372,7 +380,7 @@ func (c *Client) Unmarshal(b []byte, dst any) (err error) { } // Encode encodes the provided byte slice into a [gpb.TypedValue] using the client's encoding. -func (c *Client) Encode(b []byte) *gpb.TypedValue { +func (c *client) Encode(b []byte) *gpb.TypedValue { switch c.encoding { case gpb.Encoding_JSON: return &gpb.TypedValue{ @@ -392,7 +400,7 @@ 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) { +func (c *client) Decode(val *gpb.TypedValue) ([]byte, error) { switch c.encoding { case gpb.Encoding_JSON: v, ok := val.Value.(*gpb.TypedValue_JsonVal) diff --git a/internal/provider/cisco/gnmiext/v2/client_test.go b/internal/provider/cisco/gnmiext/v2/client_test.go index f5082b4f..cfcc1fa6 100644 --- a/internal/provider/cisco/gnmiext/v2/client_test.go +++ b/internal/provider/cisco/gnmiext/v2/client_test.go @@ -502,7 +502,7 @@ func TestClient_GetConfig(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - client := &Client{ + client := &client{ encoding: gpb.Encoding_JSON, gnmi: gpb.NewGNMIClient(test.conn), } @@ -582,7 +582,7 @@ func TestClient_GetState(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - client := &Client{ + client := &client{ encoding: gpb.Encoding_JSON, gnmi: gpb.NewGNMIClient(test.conn), } @@ -853,7 +853,7 @@ func TestClient_Update(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - client := &Client{ + client := &client{ encoding: gpb.Encoding_JSON, gnmi: gpb.NewGNMIClient(test.conn), } @@ -1015,7 +1015,7 @@ func TestClient_Patch(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - client := &Client{ + client := &client{ encoding: gpb.Encoding_JSON_IETF, gnmi: gpb.NewGNMIClient(test.conn), } @@ -1133,7 +1133,7 @@ func TestClient_Delete(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - client := &Client{ + client := &client{ encoding: gpb.Encoding_JSON, gnmi: gpb.NewGNMIClient(test.conn), } @@ -1231,7 +1231,7 @@ func TestClient_Marshal(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - client := &Client{ + client := &client{ capabilities: &Capabilities{ SupportedModels: []Model{ {Name: "openconfig-interfaces", Organization: "OpenConfig working group", Version: "2.5.0"}, @@ -1288,7 +1288,7 @@ func TestClient_Unmarshal(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - client := &Client{ + client := &client{ capabilities: &Capabilities{ SupportedModels: []Model{ {Name: "openconfig-interfaces", Organization: "OpenConfig working group", Version: "2.5.0"}, diff --git a/internal/provider/cisco/iosxr/provider.go b/internal/provider/cisco/iosxr/provider.go new file mode 100644 index 00000000..da0b7d26 --- /dev/null +++ b/internal/provider/cisco/iosxr/provider.go @@ -0,0 +1,155 @@ +package iosxr + +import ( + "context" + "fmt" + + "github.com/ironcore-dev/network-operator/internal/deviceutil" + "github.com/ironcore-dev/network-operator/internal/provider" + "github.com/ironcore-dev/network-operator/internal/provider/cisco/gnmiext/v2" + "google.golang.org/grpc" +) + +var ( + _ provider.Provider = &Provider{} + _ provider.InterfaceProvider = &Provider{} +) + +type Provider struct { + conn *grpc.ClientConn + client gnmiext.Client +} + +func NewProvider() provider.Provider { + return &Provider{} +} + +func (p *Provider) Connect(ctx context.Context, conn *deviceutil.Connection) (err error) { + p.conn, err = deviceutil.NewGrpcClient(ctx, conn) + if err != nil { + return fmt.Errorf("failed to create grpc connection: %w", err) + } + p.client, err = gnmiext.New(ctx, p.conn) + if err != nil { + return err + } + return nil +} + +func (p *Provider) Disconnect(ctx context.Context, conn *deviceutil.Connection) error { + return p.conn.Close() +} + +func (p *Provider) EnsureInterface(ctx context.Context, req *provider.InterfaceRequest) error { + if p.client == nil { + return fmt.Errorf("client is not connected") + } + var name string = req.Interface.Spec.Name + + var physif *PhisIf = NewIface(name) + + physif.Name = req.Interface.Spec.Name + physif.Description = req.Interface.Spec.Description + + physif.Statistics.LoadInterval = 30 + owner, err := ExractMTUOwnerFromIfaceName(name) + if err != nil { + return fmt.Errorf("failed to extract MTU owner from interface name %s: %w", name, err) + } + physif.MTUs = MTUs{MTU: []MTU{{MTU: uint16(req.Interface.Spec.MTU), Owner: string(owner)}}} + + // (fixme): for the moment it is enought to keep this static + // option1: extend existing interface spec + // option2: create a custom iosxr config + physif.Shutdown = Empty(false) + physif.Statistics.LoadInterval = uint8(30) + + if len(req.Interface.Spec.IPv4.Addresses) == 0 { + return fmt.Errorf("no IPv4 address configured for interface %s", name) + } + + if len(req.Interface.Spec.IPv4.Addresses) > 1 { + return fmt.Errorf("only a single primary IPv4 address is supported for interface %s", name) + } + + // (fixme): support IPv6 addresses, IPv6 neighbor config + ip := req.Interface.Spec.IPv4.Addresses[0].Prefix.Addr().String() + ipNet := req.Interface.Spec.IPv4.Addresses[0].Prefix.Bits() + if err != nil { + return fmt.Errorf("failed to parse IPv4 address %s: %w", req.Interface.Spec.IPv4.Addresses[0], err) + } + + physif.IPv4Network = IPv4Network{ + Addresses: AddressesIPv4{ + Primary: Primary{ + Address: ip, + Netmask: string(ipNet), + }, + }, + } + + // Check if interface exists otherwise patch will fail + var tmpiFace *PhisIf = NewIface(name) + err = p.client.GetConfig(ctx, tmpiFace) + if err != nil { + // Interface does not exist, create it + err = p.client.Update(ctx, physif) + if err != nil { + return fmt.Errorf("failed to create interface %s: %w", req.Interface.Spec.Name, err) + } + fmt.Printf("Interface %s created successfully\n", req.Interface.Spec.Name) + return nil + } + + err = p.client.Patch(ctx, physif) + if err != nil { + return err + } + + return nil +} + +func (p *Provider) DeleteInterface(ctx context.Context, req *provider.InterfaceRequest) error { + var iFace = NewIface(req.Interface.Spec.Name) + + if p.client == nil { + return fmt.Errorf("client is not connected") + } + + err := p.client.Delete(ctx, iFace) + if err != nil { + return fmt.Errorf("failed to delete interface %s: %w", req.Interface.Spec.Name, err) + } + return nil +} + +func (p *Provider) GetInterfaceStatus(ctx context.Context, req *provider.InterfaceRequest) (provider.InterfaceStatus, error) { + state := new(PhysIfState) + state.Name = req.Interface.Spec.Name + + if p.client == nil { + return provider.InterfaceStatus{}, fmt.Errorf("client is not connected") + } + + states, err := p.client.GetStateWithMultipleUpdates(ctx, state) + + if err != nil { + return provider.InterfaceStatus{}, fmt.Errorf("failed to get interface status for %s: %w", req.Interface.Spec.Name, err) + } + + providerStatus := provider.InterfaceStatus{ + OperStatus: true, + } + for _, s := range *states { + currState := s.(*PhysIfState) + if stateMapping[currState.State] != StateUp { + providerStatus.OperStatus = false + break + } + } + return providerStatus, nil +} + +func init() { + provider.Register("cisco-iosxr-gnmi", NewProvider) +} diff --git a/internal/provider/cisco/nxos/intf.go b/internal/provider/cisco/nxos/intf.go index a79cab8d..d72d17e1 100644 --- a/internal/provider/cisco/nxos/intf.go +++ b/internal/provider/cisco/nxos/intf.go @@ -373,7 +373,7 @@ func Range(r []int32) string { } // Exists checks if all provided interface names exist on the device. -func Exists(ctx context.Context, client *gnmiext.Client, names ...string) (bool, error) { +func Exists(ctx context.Context, client gnmiext.Client, names ...string) (bool, error) { if len(names) == 0 { return false, errors.New("at least one interface name must be provided") } diff --git a/internal/provider/cisco/nxos/provider.go b/internal/provider/cisco/nxos/provider.go index d89057a7..1f189942 100644 --- a/internal/provider/cisco/nxos/provider.go +++ b/internal/provider/cisco/nxos/provider.go @@ -62,7 +62,7 @@ var ( type Provider struct { conn *grpc.ClientConn - client *gnmiext.Client + client gnmiext.Client } func NewProvider() provider.Provider { From 09efcb5e37cb28424aed1bf8caf9d71a889a1eb4 Mon Sep 17 00:00:00 2001 From: Sven Rosenzweig Date: Wed, 1 Oct 2025 17:26:37 +0200 Subject: [PATCH 3/4] feat: Add Cisco IOS-XR Provider and implement interface stubs --- internal/provider/cisco/iosxr/intf.go | 136 ++++++++++++ internal/provider/cisco/iosxr/intf_test.go | 50 +++++ internal/provider/cisco/iosxr/provider.go | 85 +++---- .../provider/cisco/iosxr/provider_test.go | 207 ++++++++++++++++++ .../provider/cisco/iosxr/testdata/intf.json | 45 ++++ .../cisco/iosxr/testdata/intf.json.txt | 5 + 6 files changed, 491 insertions(+), 37 deletions(-) create mode 100644 internal/provider/cisco/iosxr/intf.go create mode 100644 internal/provider/cisco/iosxr/intf_test.go create mode 100644 internal/provider/cisco/iosxr/provider_test.go create mode 100644 internal/provider/cisco/iosxr/testdata/intf.json create mode 100644 internal/provider/cisco/iosxr/testdata/intf.json.txt diff --git a/internal/provider/cisco/iosxr/intf.go b/internal/provider/cisco/iosxr/intf.go new file mode 100644 index 00000000..76045b68 --- /dev/null +++ b/internal/provider/cisco/iosxr/intf.go @@ -0,0 +1,136 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package iosxr + +import ( + "fmt" + "regexp" + + "github.com/ironcore-dev/network-operator/internal/provider/cisco/gnmiext/v2" +) + +type PhysIf struct { + Name string `json:"-"` + Description string `json:"description"` + Active string `json:"active"` + Vrf string `json:"Cisco-IOS-XR-infra-rsi-cfg:vrf,omitempty"` + Statistics Statistics `json:"Cisco-IOS-XR-infra-statsd-cfg:statistics,omitempty"` + IPv4Network IPv4Network `json:"Cisco-IOS-XR-ipv4-io-cfg:ipv4-network,omitempty"` + IPv6Network IPv6Network `json:"Cisco-IOS-XR-ipv6-ma-cfg:ipv6-network,omitempty"` + IPv6Neighbor IPv6Neighbor `json:"Cisco-IOS-XR-ipv6-nd-cfg:ipv6-neighbor,omitempty"` + MTUs MTUs `json:"mtus,omitempty"` + Shutdown gnmiext.Empty `json:"shutdown,omitempty"` +} + +type Statistics struct { + LoadInterval uint8 `json:"load-interval"` +} + +type IPv4Network struct { + Addresses AddressesIPv4 `json:"addresses"` + Mtu uint16 `json:"mtu"` +} + +type AddressesIPv4 struct { + Primary Primary `json:"primary"` +} + +type Primary struct { + Address string `json:"address"` + Netmask string `json:"netmask"` +} + +type IPv6Network struct { + Mtu uint16 `json:"mtu"` + Addresses AddressesIPv6 `json:"addresses"` +} + +type AddressesIPv6 struct { + RegularAddresses RegularAddresses `json:"regular-addresses"` +} + +type RegularAddresses struct { + RegularAddress []RegularAddress `json:"regular-address"` +} + +type RegularAddress struct { + Address string `json:"address"` + PrefixLength uint8 `json:"prefix-length"` + Zone string `json:"zone"` +} + +type IPv6Neighbor struct { + RASuppress bool `json:"ra-suppress"` +} + +type MTUs struct { + MTU []MTU `json:"mtu"` +} + +type MTU struct { + MTU int32 `json:"mtu"` + Owner string `json:"owner"` +} + +func (i *PhysIf) XPath() string { + return fmt.Sprintf("Cisco-IOS-XR-ifmgr-cfg:interface-configurations/interface-configuration[active=act][interface-name=%s]", i.Name) +} + +func (i *PhysIf) String() string { + return fmt.Sprintf("Name: %s, Description=%s, ShutDown=%t", i.Name, i.Description, i.Shutdown) +} + +type IFaceSpeed string + +const ( + Speed10G IFaceSpeed = "TenGigE" + Speed25G IFaceSpeed = "TwentyFiveGigE" + Speed40G IFaceSpeed = "FortyGigE" + Speed100G IFaceSpeed = "HundredGigE" +) + +func ExtractMTUOwnerFromIfaceName(ifaceName string) (IFaceSpeed, error) { + // Match the port_type in an interface name /// + // E.g. match TwentyFiveGigE of interface with name TwentyFiveGigE0/0/0/1 + re := regexp.MustCompile(`^\D*`) + + mtuOwner := string(re.Find([]byte(ifaceName))) + + if mtuOwner == "" { + return "", fmt.Errorf("failed to extract MTU owner from interface name %s", ifaceName) + } + + switch mtuOwner { + case string(Speed10G): + return Speed10G, nil + case string(Speed25G): + return Speed25G, nil + case string(Speed40G): + return Speed25G, nil + case string(Speed100G): + return Speed100G, nil + default: + return "", fmt.Errorf("unsupported interface type %s for MTU owner extraction", mtuOwner) + } +} + +type PhysIfStateType string + +const ( + StateUp PhysIfStateType = "im-state-up" + StateDown PhysIfStateType = "im-state-down" + StateNotReady PhysIfStateType = "im-state-not-ready" + StateAdminDown PhysIfStateType = "im-state-admin-down" + StateShutDown PhysIfStateType = "im-state-shutdown" +) + +type PhysIfState struct { + State string `json:"state"` + Name string `json:"-"` +} + +func (phys *PhysIfState) XPath() string { + // (fixme): hardcoded route processor for the moment + return fmt.Sprintf("Cisco-IOS-XR-ifmgr-oper:interface-properties/data-nodes/data-node[data-node-name=0/RP0/CPU0]/system-view/interfaces/interface[interface-name=%s]", phys.Name) +} diff --git a/internal/provider/cisco/iosxr/intf_test.go b/internal/provider/cisco/iosxr/intf_test.go new file mode 100644 index 00000000..c5cc84d3 --- /dev/null +++ b/internal/provider/cisco/iosxr/intf_test.go @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package iosxr + +func init() { + name := "TwentyFiveGigE0/0/0/14" + + mtu := MTU{ + MTU: 9026, + Owner: "TwentyFiveGigE", + } + + Register("intf", &PhysIf{ + Name: name, + Description: "random interface test", + Active: "act", + Vrf: "default", + Statistics: Statistics{ + LoadInterval: 30, + }, + MTUs: MTUs{ + []MTU{mtu}, + }, + Shutdown: true, + IPv4Network: IPv4Network{ + Addresses: AddressesIPv4{ + Primary: Primary{ + Address: "192.168.1.2", + Netmask: "255.255.255.0", + }, + }, + Mtu: 1000, + }, + IPv6Network: IPv6Network{ + Mtu: 2100, + Addresses: AddressesIPv6{ + RegularAddresses: RegularAddresses{ + RegularAddress: []RegularAddress{ + { + Address: "2001:db8::1", + PrefixLength: 64, + Zone: "", + }, + }, + }, + }, + }, + }) +} diff --git a/internal/provider/cisco/iosxr/provider.go b/internal/provider/cisco/iosxr/provider.go index da0b7d26..1d58a522 100644 --- a/internal/provider/cisco/iosxr/provider.go +++ b/internal/provider/cisco/iosxr/provider.go @@ -1,12 +1,20 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + package iosxr import ( "context" + "errors" "fmt" + "strconv" "github.com/ironcore-dev/network-operator/internal/deviceutil" "github.com/ironcore-dev/network-operator/internal/provider" "github.com/ironcore-dev/network-operator/internal/provider/cisco/gnmiext/v2" + + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" + "google.golang.org/grpc" ) @@ -40,68 +48,78 @@ func (p *Provider) Disconnect(ctx context.Context, conn *deviceutil.Connection) return p.conn.Close() } -func (p *Provider) EnsureInterface(ctx context.Context, req *provider.InterfaceRequest) error { +func (p *Provider) EnsureInterface(ctx context.Context, req *provider.EnsureInterfaceRequest) error { if p.client == nil { - return fmt.Errorf("client is not connected") + return errors.New("client is not connected") } - var name string = req.Interface.Spec.Name - var physif *PhisIf = NewIface(name) + if req.Interface.Spec.Type != v1alpha1.InterfaceTypePhysical { + message := "unsupported interface type for interface " + req.Interface.Spec.Name + return errors.New(message) + } + + name := req.Interface.Spec.Name + + physif := &PhysIf{} physif.Name = req.Interface.Spec.Name physif.Description = req.Interface.Spec.Description physif.Statistics.LoadInterval = 30 - owner, err := ExractMTUOwnerFromIfaceName(name) + owner, err := ExtractMTUOwnerFromIfaceName(name) if err != nil { - return fmt.Errorf("failed to extract MTU owner from interface name %s: %w", name, err) + message := "failed to extract MTU owner from interface name" + name + return errors.New(message) } - physif.MTUs = MTUs{MTU: []MTU{{MTU: uint16(req.Interface.Spec.MTU), Owner: string(owner)}}} + physif.MTUs = MTUs{MTU: []MTU{{MTU: req.Interface.Spec.MTU, Owner: string(owner)}}} - // (fixme): for the moment it is enought to keep this static + // (fixme): for the moment it is enough to keep this static // option1: extend existing interface spec // option2: create a custom iosxr config - physif.Shutdown = Empty(false) + physif.Shutdown = gnmiext.Empty(false) + if req.Interface.Spec.AdminState == v1alpha1.AdminStateDown { + physif.Shutdown = gnmiext.Empty(true) + } physif.Statistics.LoadInterval = uint8(30) if len(req.Interface.Spec.IPv4.Addresses) == 0 { - return fmt.Errorf("no IPv4 address configured for interface %s", name) + message := "no IPv4 address configured for interface " + name + return errors.New(message) } if len(req.Interface.Spec.IPv4.Addresses) > 1 { - return fmt.Errorf("only a single primary IPv4 address is supported for interface %s", name) + message := "multiple IPv4 addresses configured for interface " + name + return errors.New(message) } // (fixme): support IPv6 addresses, IPv6 neighbor config - ip := req.Interface.Spec.IPv4.Addresses[0].Prefix.Addr().String() - ipNet := req.Interface.Spec.IPv4.Addresses[0].Prefix.Bits() - if err != nil { - return fmt.Errorf("failed to parse IPv4 address %s: %w", req.Interface.Spec.IPv4.Addresses[0], err) - } + ip := req.Interface.Spec.IPv4.Addresses[0].Addr().String() + ipNet := req.Interface.Spec.IPv4.Addresses[0].Bits() physif.IPv4Network = IPv4Network{ Addresses: AddressesIPv4{ Primary: Primary{ Address: ip, - Netmask: string(ipNet), + Netmask: strconv.Itoa(ipNet), }, }, } // Check if interface exists otherwise patch will fail - var tmpiFace *PhisIf = NewIface(name) - err = p.client.GetConfig(ctx, tmpiFace) + tmpPhysif := &PhysIf{} + tmpPhysif.Name = name + + err = p.client.GetConfig(ctx, tmpPhysif) if err != nil { // Interface does not exist, create it err = p.client.Update(ctx, physif) if err != nil { return fmt.Errorf("failed to create interface %s: %w", req.Interface.Spec.Name, err) } - fmt.Printf("Interface %s created successfully\n", req.Interface.Spec.Name) return nil } - err = p.client.Patch(ctx, physif) + err = p.client.Update(ctx, physif) if err != nil { return err } @@ -110,13 +128,14 @@ func (p *Provider) EnsureInterface(ctx context.Context, req *provider.InterfaceR } func (p *Provider) DeleteInterface(ctx context.Context, req *provider.InterfaceRequest) error { - var iFace = NewIface(req.Interface.Spec.Name) + physif := &PhysIf{} + physif.Name = req.Interface.Spec.Name if p.client == nil { - return fmt.Errorf("client is not connected") + return errors.New("client is not connected") } - err := p.client.Delete(ctx, iFace) + err := p.client.Delete(ctx, physif) if err != nil { return fmt.Errorf("failed to delete interface %s: %w", req.Interface.Spec.Name, err) } @@ -128,26 +147,18 @@ func (p *Provider) GetInterfaceStatus(ctx context.Context, req *provider.Interfa state.Name = req.Interface.Spec.Name if p.client == nil { - return provider.InterfaceStatus{}, fmt.Errorf("client is not connected") + return provider.InterfaceStatus{}, errors.New("client is not connected") } - states, err := p.client.GetStateWithMultipleUpdates(ctx, state) + err := p.client.GetState(ctx, state) if err != nil { return provider.InterfaceStatus{}, fmt.Errorf("failed to get interface status for %s: %w", req.Interface.Spec.Name, err) } - providerStatus := provider.InterfaceStatus{ - OperStatus: true, - } - for _, s := range *states { - currState := s.(*PhysIfState) - if stateMapping[currState.State] != StateUp { - providerStatus.OperStatus = false - break - } - } - return providerStatus, nil + return provider.InterfaceStatus{ + OperStatus: state.State == string(StateUp), + }, nil } func init() { diff --git a/internal/provider/cisco/iosxr/provider_test.go b/internal/provider/cisco/iosxr/provider_test.go new file mode 100644 index 00000000..0ec09c39 --- /dev/null +++ b/internal/provider/cisco/iosxr/provider_test.go @@ -0,0 +1,207 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package iosxr + +import ( + "bytes" + "context" + "encoding/json" + "net/netip" + "os" + "strings" + "testing" + + "github.com/tidwall/gjson" + + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" + "github.com/ironcore-dev/network-operator/internal/provider" + "github.com/ironcore-dev/network-operator/internal/provider/cisco/gnmiext/v2" +) + +type TestCase struct { + name string + val gnmiext.Configurable +} + +var tests []TestCase + +func Register(name string, val gnmiext.Configurable) { + tests = append(tests, TestCase{ + name: name, + val: val, + }) +} + +func removeRootElement(xpath string) string { + parts := strings.Split(xpath, "/") + if len(parts) == 1 { + return xpath + } + return strings.Join(parts[1:], "/") +} + +func Test_Payload(t *testing.T) { + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + b, err := json.Marshal(test.val) + if err != nil { + t.Errorf("json.Marshal() error = %v", err) + return + } + + file := "testdata/" + test.name + ".json" + data, err := os.ReadFile(file) + if err != nil { + t.Fatalf("os.ReadFile(%q) error = %v", file, err) + } + + var buf bytes.Buffer + if err := json.Compact(&buf, data); err != nil { + t.Errorf("json.Compact() error = %v", err) + return + } + + xpath := removeRootElement(test.val.XPath()) + path, err := gnmiext.StringToStructuredPath(xpath) + if err != nil { + t.Errorf("StringToStructuredPath(%q) error = %v", xpath, err) + return + } + + var sb strings.Builder + for _, elem := range path.GetElem() { + if elem.GetName() == "" { + continue + } + if sb.Len() > 0 { + sb.WriteByte('|') + } + sb.WriteString(elem.GetName()) + } + + res := gjson.GetBytes(buf.Bytes(), sb.String()) + if want := []byte(res.Raw); !bytes.Equal(want, b) { + t.Errorf("payload mismatch:\nwant: %s\ngot: %s", want, b) + } + }) + } +} + +// MockClient provides a mock implementation of gnmiext.Client for testing. +type MockClient struct { + // Function fields for mocking different methods + GetConfigFunc func(ctx context.Context, conf ...gnmiext.Configurable) error + PatchFunc func(ctx context.Context, conf ...gnmiext.Configurable) error + UpdateFunc func(ctx context.Context, conf ...gnmiext.Configurable) error + DeleteFunc func(ctx context.Context, conf ...gnmiext.Configurable) error + GetStateFunc func(ctx context.Context, conf ...gnmiext.Configurable) error +} + +// Implement the methods that Provider uses +func (m *MockClient) GetConfig(ctx context.Context, conf ...gnmiext.Configurable) error { + if m.GetConfigFunc != nil { + return m.GetConfigFunc(ctx, conf...) + } + return nil +} + +func (m *MockClient) Patch(ctx context.Context, conf ...gnmiext.Configurable) error { + return nil +} + +func (m *MockClient) Update(ctx context.Context, conf ...gnmiext.Configurable) error { + return nil +} + +func (m *MockClient) Delete(ctx context.Context, conf ...gnmiext.Configurable) error { + return nil +} + +func (m *MockClient) GetState(ctx context.Context, conf ...gnmiext.Configurable) error { + if m.GetStateFunc != nil { + return m.GetStateFunc(ctx, conf...) + } + return nil +} + +func Test_EnsureInterface(t *testing.T) { + m := &MockClient{} + + p := &Provider{ + client: m, + conn: nil, + } + + ctx := context.Background() + + var name = "TwentyFiveGigE0/0/0/14" + var prefix netip.Prefix + + prefix, err := netip.ParsePrefix("192.168.1.0/24") + + if err != nil { + t.Fatalf("Failed to parse prefix: %v", err) + } + + ipv4 := v1alpha1.InterfaceIPv4{ + Addresses: []v1alpha1.IPPrefix{ + { + Prefix: prefix, + }, + }, + } + + req := &provider.EnsureInterfaceRequest{ + Interface: &v1alpha1.Interface{ + Spec: v1alpha1.InterfaceSpec{ + Name: name, + IPv4: &ipv4, + Description: "i572056-test-2", + AdminState: "UP", + Type: "Physical", + MTU: 9600, + }, + }, + } + + err = p.EnsureInterface(ctx, req) + if err != nil { + t.Fatalf("EnsureInterface() error = %v", err) + } +} + +func Test_GetState(t *testing.T) { + m := &MockClient{ + GetStateFunc: func(ctx context.Context, conf ...gnmiext.Configurable) error { + conf[0].(*PhysIfState).State = "im-state-up" + return nil + }, + } + + p := &Provider{ + client: m, + conn: nil, + } + + ctx := context.Background() + + var name = "TwentyFiveGigE0/0/0/14" + + req := &provider.InterfaceRequest{ + Interface: &v1alpha1.Interface{ + Spec: v1alpha1.InterfaceSpec{ + Name: name, + }, + }, + } + + status, err := p.GetInterfaceStatus(ctx, req) + if err != nil { + t.Fatalf("EnsureInterface() error = %v", err) + } + + if status.OperStatus != true { + t.Fatalf("GetInterfaceStatus() expected OperStatus=true, got false") + } +} diff --git a/internal/provider/cisco/iosxr/testdata/intf.json b/internal/provider/cisco/iosxr/testdata/intf.json new file mode 100644 index 00000000..ccef73d4 --- /dev/null +++ b/internal/provider/cisco/iosxr/testdata/intf.json @@ -0,0 +1,45 @@ +{ + "interface-configuration": { + "description": "random interface test", + "active": "act", + "Cisco-IOS-XR-infra-rsi-cfg:vrf": "default", + "Cisco-IOS-XR-infra-statsd-cfg:statistics": { + "load-interval": 30 + }, + "Cisco-IOS-XR-ipv4-io-cfg:ipv4-network": { + "addresses": { + "primary": { + "address": "192.168.1.2", + "netmask": "255.255.255.0" + } + }, + "mtu": 1000 + }, + "Cisco-IOS-XR-ipv6-ma-cfg:ipv6-network": { + "mtu": 2100, + "addresses": { + "regular-addresses": { + "regular-address": [ + { + "address": "2001:db8::1", + "prefix-length": 64, + "zone": "" + } + ] + } + } + }, + "Cisco-IOS-XR-ipv6-nd-cfg:ipv6-neighbor": { + "ra-suppress": false + }, + "mtus": { + "mtu": [ + { + "mtu": 9026, + "owner": "TwentyFiveGigE" + } + ] + }, + "shutdown": [null] + } +} diff --git a/internal/provider/cisco/iosxr/testdata/intf.json.txt b/internal/provider/cisco/iosxr/testdata/intf.json.txt new file mode 100644 index 00000000..80e80638 --- /dev/null +++ b/internal/provider/cisco/iosxr/testdata/intf.json.txt @@ -0,0 +1,5 @@ +interface TwentyFiveGigE0/0/0/14 + description test + mtu 9026 + ipv4 address 192.168.1.2 255.255.255.0 + load-interval 30 From 04d7ceeb2104ad4663a9036015ddf2ec6e7283ea Mon Sep 17 00:00:00 2001 From: Sven Rosenzweig Date: Thu, 8 Jan 2026 17:55:54 +0100 Subject: [PATCH 4/4] style: Cisco IOS XR ignore yang tags spelling --- .typos.toml | 4 ++++ typosconf.toml | 4 ++++ 2 files changed, 8 insertions(+) create mode 100644 typosconf.toml diff --git a/.typos.toml b/.typos.toml index 6737dd25..e9a27abc 100644 --- a/.typos.toml +++ b/.typos.toml @@ -1,5 +1,9 @@ # SPDX-FileCopyrightText: 2026 SAP SE # SPDX-License-Identifier: Apache-2.0 +[default] +extend-ignore-re = [ + "Cisco-IOS-XR.*" +] [default.extend-words] ser = "ser" diff --git a/typosconf.toml b/typosconf.toml new file mode 100644 index 00000000..d0c392af --- /dev/null +++ b/typosconf.toml @@ -0,0 +1,4 @@ +[default] +extend-ignore-re = [ + "Cisco-IOS-XR.*" +]