Skip to content

Commit 8a9d6df

Browse files
Add NX-API JSON-RPC client and hack/nxapi helper
Introduce NXAPIClient in internal/provider/cisco/nxos/nxapi.go, a thin HTTP client for the Cisco NX-OS NX-API JSON-RPC endpoint. Add hack/nxapi, a minimal CLI that sends one or more NX-API commands to a device and pretty-prints the JSON results.
1 parent c50156d commit 8a9d6df

4 files changed

Lines changed: 654 additions & 2 deletions

File tree

hack/nxapi/main.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
// nxapi is a minimal CLI tool for sending NX-API JSON-RPC commands to a
5+
// Cisco NX-OS device and printing the results.
6+
//
7+
// Usage:
8+
//
9+
// go run ./hack/nxapi [flags] <cmd> [<cmd> ...]
10+
//
11+
// Example:
12+
//
13+
// go run ./hack/nxapi -address 10.0.0.1:443 "show version" "show interface brief"
14+
package main
15+
16+
import (
17+
"context"
18+
"encoding/json"
19+
"errors"
20+
"flag"
21+
"fmt"
22+
"os"
23+
"os/signal"
24+
"syscall"
25+
26+
"github.com/ironcore-dev/network-operator/internal/deviceutil"
27+
"github.com/ironcore-dev/network-operator/internal/provider/cisco/nxos"
28+
)
29+
30+
var fs = flag.NewFlagSet("nxapi", flag.ContinueOnError)
31+
32+
func usage() {
33+
fmt.Fprintf(os.Stderr, "Usage: nxapi [flags] <cmd> [<cmd> ...]\n\n")
34+
fmt.Fprintf(os.Stderr, "Sends NX-API JSON-RPC commands to a Cisco NX-OS device.\n\n")
35+
fmt.Fprintf(os.Stderr, "Flags:\n")
36+
fs.PrintDefaults()
37+
fmt.Fprintf(os.Stderr, "\nExample:\n")
38+
fmt.Fprintf(os.Stderr, " nxapi -address 10.0.0.1:8080 \"show version\" \"show interface brief\"\n")
39+
}
40+
41+
func main() {
42+
fs.Usage = usage
43+
44+
address := fs.String("address", "localhost:8080", "device address (host:port)")
45+
username := fs.String("username", "admin", "NX-API username")
46+
password := fs.String("password", "admin", "NX-API password")
47+
48+
if err := fs.Parse(os.Args[1:]); err != nil {
49+
// flag.ContinueOnError: -h/--help prints usage and returns ErrHelp;
50+
// other parse errors are already printed by the FlagSet.
51+
return
52+
}
53+
54+
cmds := fs.Args()
55+
if len(cmds) == 0 {
56+
fs.Usage()
57+
os.Exit(1)
58+
}
59+
60+
conn := &deviceutil.Connection{
61+
Address: *address,
62+
Username: *username,
63+
Password: *password,
64+
}
65+
66+
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt /* == syscall.SIGINT */, syscall.SIGTERM)
67+
defer cancel()
68+
69+
c, err := nxos.NewNXAPIClient(conn, 0)
70+
if err != nil {
71+
fmt.Fprintf(os.Stderr, "error: %v\n", err)
72+
return
73+
}
74+
75+
res, err := c.Do(ctx, nxos.NewRequest(cmds...))
76+
if err != nil {
77+
if errs, ok := errors.AsType[nxos.RPCErrors](err); ok {
78+
for _, e := range errs {
79+
fmt.Fprintf(os.Stderr, "RPC error %d: %s\n", e.Code, e.Message)
80+
if len(e.Data) > 0 {
81+
fmt.Fprintf(os.Stderr, " data: %s\n", e.Data)
82+
}
83+
}
84+
return
85+
}
86+
fmt.Fprintf(os.Stderr, "error: %v\n", err)
87+
return
88+
}
89+
90+
for i, r := range res {
91+
fmt.Printf("=== [%d] %s ===\n", i+1, cmds[i])
92+
var pretty any
93+
if err := json.Unmarshal(r, &pretty); err != nil {
94+
fmt.Println(string(r))
95+
continue
96+
}
97+
out, err := json.MarshalIndent(pretty, "", " ")
98+
if err != nil {
99+
fmt.Fprintf(os.Stderr, "error: failed to pretty-print JSON: %v\n", err)
100+
fmt.Println(string(r))
101+
continue
102+
}
103+
fmt.Println(string(out))
104+
}
105+
}
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package nxos
5+
6+
import (
7+
"bytes"
8+
"context"
9+
"encoding/json"
10+
"errors"
11+
"fmt"
12+
"io"
13+
"net/http"
14+
"net/url"
15+
"time"
16+
17+
"github.com/ironcore-dev/network-operator/internal/deviceutil"
18+
)
19+
20+
// The RoundTripFunc type is an adapter to allow the use of
21+
// ordinary functions as [http.RoundTripper]. If f is a function
22+
// with the appropriate signature, RoundTripFunc(f) is a
23+
// [http.RoundTripper] that calls f.
24+
type RoundTripFunc func(*http.Request) (*http.Response, error)
25+
26+
// RoundTrip returns f(r).
27+
func (f RoundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
28+
return f(r)
29+
}
30+
31+
// NXAPIClient sends JSON-RPC requests to a Cisco NX-OS device via NX-API.
32+
// Use [NewNXAPIClient] to construct one.
33+
type NXAPIClient struct {
34+
client *http.Client
35+
uri string
36+
}
37+
38+
// NewNXAPIClient creates a new [NXAPIClient] for the given connection.
39+
// If the connection has a TLS configuration set, HTTPS is used; otherwise HTTP.
40+
// The underlying HTTP client uses timeout for all requests; a value of 0
41+
// means no timeout.
42+
func NewNXAPIClient(c *deviceutil.Connection, timeout time.Duration) (*NXAPIClient, error) {
43+
proto := "http"
44+
if c.TLS != nil {
45+
proto = "https"
46+
}
47+
uri, err := url.JoinPath(proto+"://"+c.Address, "ins")
48+
if err != nil {
49+
return nil, fmt.Errorf("nxapi: failed to join path: %w", err)
50+
}
51+
transport := http.DefaultTransport.(*http.Transport).Clone()
52+
if c.TLS != nil {
53+
transport.TLSClientConfig = c.TLS
54+
}
55+
return &NXAPIClient{
56+
client: &http.Client{
57+
Transport: RoundTripFunc(func(r *http.Request) (*http.Response, error) {
58+
r.Header.Set("Content-Type", "application/json-rpc")
59+
r.Header.Set("Cache-Control", "no-cache")
60+
r.SetBasicAuth(c.Username, c.Password)
61+
return transport.RoundTrip(r)
62+
}),
63+
Timeout: timeout,
64+
},
65+
uri: uri,
66+
}, nil
67+
}
68+
69+
// Do sends a Request to the device and returns one [json.RawMessage] per
70+
// command, in the same order as the request. If any command fails, Do returns
71+
// an [RPCErrors] containing one [RPCError] per failed command; transport and
72+
// HTTP errors are returned directly.
73+
func (c *NXAPIClient) Do(ctx context.Context, r Request) ([]json.RawMessage, error) {
74+
b, err := r.Encode()
75+
if err != nil {
76+
return nil, fmt.Errorf("nxapi: failed to encode request: %w", err)
77+
}
78+
79+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.uri, bytes.NewReader(b))
80+
if err != nil {
81+
return nil, fmt.Errorf("nxapi: failed to create request: %w", err)
82+
}
83+
84+
resp, err := c.client.Do(req)
85+
if err != nil {
86+
return nil, fmt.Errorf("nxapi: failed to send request: %w", err)
87+
}
88+
defer resp.Body.Close()
89+
90+
body, err := io.ReadAll(resp.Body)
91+
if err != nil {
92+
return nil, fmt.Errorf("nxapi: failed to read response body: %w", err)
93+
}
94+
95+
res, err := decode(body)
96+
if err != nil {
97+
return nil, fmt.Errorf("nxapi: failed to decode response: %w", err)
98+
}
99+
100+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
101+
var errs RPCErrors
102+
for _, r := range res {
103+
if r.Error != nil {
104+
errs = append(errs, r.Error)
105+
}
106+
}
107+
if len(errs) == 0 {
108+
return nil, &HTTPError{Code: resp.StatusCode, Body: body}
109+
}
110+
return nil, errs
111+
}
112+
113+
msg := make([]json.RawMessage, len(res))
114+
for i, r := range res {
115+
msg[i] = r.Body.Data
116+
}
117+
118+
return msg, nil
119+
}
120+
121+
// Request is an ordered list of commands to send in a single JSON-RPC batch.
122+
type Request []cmd
123+
124+
// NewRequest creates a Request from plain CLI command strings.
125+
func NewRequest(cmds ...string) Request {
126+
r := make(Request, len(cmds))
127+
for i, c := range cmds {
128+
r[i] = cmd{
129+
Jsonrpc: "2.0",
130+
// Other possible values are "cli_ascii" and "cli_array".
131+
// For now, we only support "cli" which is the default.
132+
Method: "cli",
133+
Params: params{
134+
Cmd: c,
135+
// Static NX-API version.
136+
Version: 1,
137+
},
138+
ID: i + 1,
139+
}
140+
}
141+
return r
142+
}
143+
144+
// WithRollback sets the error action on each command in the request,
145+
// controlling what NX-OS does if that individual command fails.
146+
func (r Request) WithRollback(a ErrorAction) Request {
147+
for i := range r {
148+
r[i].Rollback = a
149+
}
150+
return r
151+
}
152+
153+
// Encode serialises the request to JSON.
154+
func (r Request) Encode() ([]byte, error) {
155+
return json.Marshal(r)
156+
}
157+
158+
// cmd represents a single JSON-RPC command within a [Request].
159+
type cmd struct {
160+
Jsonrpc string `json:"jsonrpc"`
161+
Method string `json:"method"`
162+
Params params `json:"params"`
163+
ID int `json:"id"`
164+
Rollback ErrorAction `json:"rollback,omitempty"`
165+
}
166+
167+
// params holds the NX-API specific parameters for a [cmd].
168+
type params struct {
169+
Cmd string `json:"cmd"`
170+
Version int `json:"version"`
171+
}
172+
173+
// ErrorAction controls how NX-OS responds when an individual command fails.
174+
type ErrorAction string
175+
176+
const (
177+
Stop ErrorAction = "stop-on-error"
178+
Continue ErrorAction = "continue-on-error"
179+
Rollback ErrorAction = "rollback-on-error"
180+
)
181+
182+
// res is the shared JSON-RPC response envelope.
183+
type res struct {
184+
Error *RPCError `json:"error"`
185+
Body struct {
186+
Data json.RawMessage `json:"body"`
187+
} `json:"result"`
188+
}
189+
190+
// decode attempts to parse the response body as either a single JSON-RPC response
191+
// or a batch of responses, returning a slice of [res] values in either case.
192+
func decode(body []byte) ([]res, error) {
193+
if len(body) > 0 && body[0] == '{' {
194+
var r res
195+
if err := json.Unmarshal(body, &r); err != nil {
196+
return nil, err
197+
}
198+
return []res{r}, nil
199+
}
200+
var r []res
201+
if err := json.Unmarshal(body, &r); err != nil {
202+
return nil, err
203+
}
204+
return r, nil
205+
}
206+
207+
// RPCError represents a single JSON-RPC error returned by NX-OS.
208+
type RPCError struct {
209+
// Code is the JSON-RPC error code.
210+
Code int `json:"code"`
211+
// Message is the human-readable error description.
212+
Message string `json:"message"`
213+
// Data contains additional error context as raw JSON, if present.
214+
Data json.RawMessage `json:"data"`
215+
}
216+
217+
func (e *RPCError) Error() string {
218+
return fmt.Sprintf("nxapi: RPC error %d: %s", e.Code, e.Message)
219+
}
220+
221+
// RPCErrors is a slice of [RPCError] values returned when one or more commands
222+
// in a request fail. It implements the error interface.
223+
type RPCErrors []*RPCError
224+
225+
// Error implements the built-in error interface.
226+
func (e RPCErrors) Error() string {
227+
errs := make([]error, len(e))
228+
for i, err := range e {
229+
errs[i] = err
230+
}
231+
return errors.Join(errs...).Error()
232+
}
233+
234+
// Unwrap returns the individual RPC errors for use with errors.Is and errors.As.
235+
func (e RPCErrors) Unwrap() []error {
236+
errs := make([]error, len(e))
237+
for i, err := range e {
238+
errs[i] = err
239+
}
240+
return errs
241+
}
242+
243+
// HTTPError is returned when the NX-API endpoint responds with a non-2xx
244+
// status and the body cannot be parsed as a JSON-RPC error.
245+
type HTTPError struct {
246+
// Code is the HTTP status code.
247+
Code int
248+
// Body contains the raw response body.
249+
Body []byte
250+
}
251+
252+
func (e *HTTPError) Error() string {
253+
return fmt.Sprintf("nxapi: non-2xx status code: %d - %s", e.Code, string(e.Body))
254+
}

0 commit comments

Comments
 (0)