Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 26 additions & 17 deletions internal/provider/cisco/nxos/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,14 @@ type Provider struct {
nxapi *nxapi.Client
}

// timeout is the default timeout for all HTTP/gRPC requests made by the provider.
const timeout = 30 * time.Second

func NewProvider() provider.Provider {
return &Provider{}
}

func (p *Provider) Connect(ctx context.Context, conn *deviceutil.Connection) (err error) {
// timeout is the default timeout for all HTTP/gRPC requests made by the provider.
const timeout = 30 * time.Second
p.conn, err = grpcext.NewClient(conn, grpcext.WithDefaultTimeout(timeout))
if err != nil {
return fmt.Errorf("failed to create grpc connection: %w", err)
Expand Down Expand Up @@ -141,23 +142,31 @@ func (p *Provider) FactoryReset(ctx context.Context, conn *deviceutil.Connection
return FactoryReset(ctx, p.conn)
}

func (p *Provider) Reprovision(ctx context.Context, conn *deviceutil.Connection) (reterr error) {
if err := p.Connect(ctx, conn); err != nil {
return err
func (p *Provider) Reprovision(ctx context.Context, conn *deviceutil.Connection) error {
c := *conn
c.Address = netip.MustParseAddrPort(conn.Address).String()
client, err := nxapi.NewClient(&c, timeout)
if err != nil {
return fmt.Errorf("failed to create nxapi client: %w", err)
}
defer func() {
if err := p.Disconnect(ctx, conn); err != nil {
reterr = errors.Join(reterr, err)
}
}()
// This is currently defunct on NX-OS, as enabling POAP requires a `copy running-config startup-config` which we
// cannot issue via GNMI
// TODO add once NXAPI client is available
poap := BootPOAP("enable")
if err := p.client.Update(ctx, &poap); err != nil {
return err

_, err = client.Do(ctx, nxapi.NewRequest(
"boot poap enable",
"copy running-config startup-config",
).WithRollback(nxapi.Stop))
if err != nil {
return fmt.Errorf("failed to prepare device for reprovisioning: %w", err)
}
return Reboot(ctx, p.conn)

// Reboot is issued as a separate request because it actually restarts
// the device. The connection will drop before a response is received,
// so transport errors are expected and tolerated.
_, err = client.Do(ctx, nxapi.NewRequest("reload"))
if err != nil && !nxapi.IsTransportError(err) {
return fmt.Errorf("failed to reboot device: %w", err)
}

return nil
}

func (p *Provider) ListPorts(ctx context.Context) ([]provider.DevicePort, error) {
Expand Down
122 changes: 122 additions & 0 deletions internal/provider/cisco/nxos/reprovision_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors
// SPDX-License-Identifier: Apache-2.0

package nxos

import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"slices"
"testing"

"github.com/ironcore-dev/network-operator/internal/deviceutil"
)

func TestReprovision(t *testing.T) {
t.Run("success with connection drop on reload", func(t *testing.T) {
var requests [][]string
called := 0

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var cmds []struct {
Params struct {
Cmd string `json:"cmd"`
} `json:"params"`
}
if err := json.NewDecoder(r.Body).Decode(&cmds); err != nil {
t.Fatalf("failed to decode request: %v", err)
}

batch := make([]string, len(cmds))
for i, c := range cmds {
batch[i] = c.Params.Cmd
}
requests = append(requests, batch)
called++

if called == 1 {
w.Header().Set("Content-Type", "application/json-rpc")
fmt.Fprint(w, `[
{"jsonrpc":"2.0","result":null,"id":1},
{"jsonrpc":"2.0","result":null,"id":2}
]`)
return
}

// Simulate device going down by closing connection abruptly.
hijacker, ok := w.(http.Hijacker)
if !ok {
t.Fatal("server does not support hijacking")
}
conn, _, err := hijacker.Hijack()
if err != nil {
t.Fatalf("hijack failed: %v", err)
}
conn.Close()
}))
defer srv.Close()

p := &Provider{}
conn := &deviceutil.Connection{Address: srv.Listener.Addr().String(), Username: "admin", Password: "secret"}

err := p.Reprovision(t.Context(), conn)
if err != nil {
t.Fatalf("Reprovision returned unexpected error: %v", err)
}

if len(requests) != 2 {
t.Fatalf("expected 2 NXAPI requests, got %d", len(requests))
}
if !slices.Equal(requests[0], []string{"boot poap enable", "copy running-config startup-config"}) {
t.Errorf("prep batch = %v", requests[0])
}
if !slices.Equal(requests[1], []string{"reload"}) {
t.Errorf("reload request = %v", requests[1])
}
})

t.Run("prep batch RPC error fails", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json-rpc")
w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, `[{"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid command"},"id":1}]`)
}))
defer srv.Close()

p := &Provider{}
conn := &deviceutil.Connection{Address: srv.Listener.Addr().String(), Username: "admin", Password: "secret"}

err := p.Reprovision(t.Context(), conn)
if err == nil {
t.Fatal("expected error from prep batch, got nil")
}
})

t.Run("reload RPC error fails", func(t *testing.T) {
called := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called++
w.Header().Set("Content-Type", "application/json-rpc")
if called == 1 {
fmt.Fprint(w, `[
{"jsonrpc":"2.0","result":null,"id":1},
{"jsonrpc":"2.0","result":null,"id":2}
]`)
return
}
w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, `[{"jsonrpc":"2.0","error":{"code":-32602,"message":"Permission denied"},"id":1}]`)
}))
defer srv.Close()

p := &Provider{}
conn := &deviceutil.Connection{Address: srv.Listener.Addr().String(), Username: "admin", Password: "secret"}

err := p.Reprovision(t.Context(), conn)
if err == nil {
t.Fatal("expected error from reload RPC failure, got nil")
}
})
}
8 changes: 0 additions & 8 deletions internal/provider/cisco/nxos/system.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,6 @@ func (*FirmwareVersion) XPath() string {
return "System/showversion-items/nxosVersion"
}

var _ gnmiext.DataElement = (*BootPOAP)(nil)

type BootPOAP string

func (*BootPOAP) XPath() string {
return "/System/boot-items/poap"
}

type BootTime UnixTime

func (*BootTime) XPath() string {
Expand Down
17 changes: 17 additions & 0 deletions internal/transport/nxapi/nxapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"time"
Expand Down Expand Up @@ -257,3 +258,19 @@
func (e *HTTPError) Error() string {
return fmt.Sprintf("nxapi: non-2xx status code: %d - %s", e.Code, string(e.Body))
}

// IsTransportError reports whether err is a network-level transport error
// (connection reset, timeout, EOF) as opposed to a logical error returned
// by the NX-API endpoint (RPCError, HTTPError). This is useful for callers
// that issue disruptive commands (e.g. reboot) where the device going down
// mid-request is expected.
func IsTransportError(err error) bool {
if err == nil {
return false
}
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
return true
}
_, ok := errors.AsType[net.Error](err)

Check failure on line 274 in internal/transport/nxapi/nxapi.go

View workflow job for this annotation

GitHub Actions / Check Go Code

Error return value is not checked (errcheck)
return ok
}
81 changes: 81 additions & 0 deletions internal/transport/nxapi/nxapi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"

Expand Down Expand Up @@ -295,3 +297,82 @@ func TestDo(t *testing.T) {
})
}
}

func TestIsTransportError(t *testing.T) {
tests := []struct {
desc string
err error
want bool
}{
{
desc: "nil is not a transport error",
err: nil,
want: false,
},
{
desc: "RPCError is not a transport error",
err: &RPCError{Code: -32602, Message: "Invalid params"},
want: false,
},
{
desc: "RPCErrors is not a transport error",
err: RPCErrors{&RPCError{Code: -32602, Message: "Invalid params"}},
want: false,
},
{
desc: "HTTPError is not a transport error",
err: &HTTPError{Code: 401, Body: []byte("unauthorized")},
want: false,
},
{
desc: "generic error is not a transport error",
err: errors.New("some logic error"),
want: false,
},
{
desc: "io.EOF is a transport error",
err: io.EOF,
want: true,
},
{
desc: "io.ErrUnexpectedEOF is a transport error",
err: io.ErrUnexpectedEOF,
want: true,
},
{
desc: "wrapped io.EOF is a transport error",
err: fmt.Errorf("request failed: %w", io.EOF),
want: true,
},
{
desc: "net.Error is a transport error",
err: &netError{msg: "i/o timeout"},
want: true,
},
{
desc: "url.Error wrapping net.Error is a transport error",
err: &url.Error{Op: "Post", URL: "http://x/ins", Err: &netError{msg: "i/o timeout"}},
want: true,
},
{
desc: "wrapped net.Error is a transport error",
err: fmt.Errorf("read tcp: %w", &netError{msg: "connection reset by peer"}),
want: true,
},
}
for _, test := range tests {
t.Run(test.desc, func(t *testing.T) {
got := IsTransportError(test.err)
if got != test.want {
t.Errorf("IsTransportError(%v) = %t, want %t", test.err, got, test.want)
}
})
}
}

// netError is a mock net.Error for testing.
type netError struct{ msg string }

func (e *netError) Error() string { return e.msg }
func (e *netError) Timeout() bool { return false }
func (e *netError) Temporary() bool { return false }
Loading