diff --git a/forks/goarista/gnmi/arbitration.go b/forks/goarista/gnmi/arbitration.go new file mode 100644 index 0000000000000000000000000000000000000000..78225d70240584b7e4e8b048bd833753b39ebc5e --- /dev/null +++ b/forks/goarista/gnmi/arbitration.go @@ -0,0 +1,58 @@ +// Copyright (c) 2019 Arista Networks, Inc. +// Use of this source code is governed by the Apache License 2.0 +// that can be found in the COPYING file. + +package gnmi + +import ( + "fmt" + "strconv" + "strings" + + "github.com/openconfig/gnmi/proto/gnmi_ext" +) + +// ArbitrationExt takes a string representation of a master arbitration value +// (e.g. "23", "role:42") and return a *gnmi_ext.Extension. +func ArbitrationExt(s string) (*gnmi_ext.Extension, error) { + if s == "" { + return nil, nil + } + roleID, electionID, err := parseArbitrationString(s) + if err != nil { + return nil, err + } + arb := &gnmi_ext.MasterArbitration{ + Role: &gnmi_ext.Role{Id: roleID}, + ElectionId: &gnmi_ext.Uint128{High: 0, Low: electionID}, + } + ext := gnmi_ext.Extension_MasterArbitration{MasterArbitration: arb} + return &gnmi_ext.Extension{Ext: &ext}, nil +} + +// parseArbitrationString parses the supplied string and returns the role and election id +// values. Input is of the form [<role>:]<election_id>, where election_id is a uint64. +// +// Examples: +// "1" +// "admin:42" +func parseArbitrationString(s string) (string, uint64, error) { + tokens := strings.Split(s, ":") + switch len(tokens) { + case 1: // just election id + id, err := parseElectionID(tokens[0]) + return "", id, err + case 2: // role and election id + id, err := parseElectionID(tokens[1]) + return tokens[0], id, err + } + return "", 0, fmt.Errorf("badly formed arbitration id (%s)", s) +} + +func parseElectionID(s string) (uint64, error) { + id, err := strconv.ParseUint(s, 0, 64) + if err != nil { + return 0, fmt.Errorf("badly formed arbitration id (%s)", s) + } + return id, nil +} diff --git a/forks/goarista/gnmi/arbitration_test.go b/forks/goarista/gnmi/arbitration_test.go new file mode 100644 index 0000000000000000000000000000000000000000..cdcc37c35b8c332f3ae4279b66ddf7b46e8b8798 --- /dev/null +++ b/forks/goarista/gnmi/arbitration_test.go @@ -0,0 +1,73 @@ +// Copyright (c) 2019 Arista Networks, Inc. +// Use of this source code is governed by the Apache License 2.0 +// that can be found in the COPYING file. + +package gnmi + +import ( + "fmt" + "testing" + + "github.com/aristanetworks/goarista/test" + + "github.com/openconfig/gnmi/proto/gnmi_ext" +) + +func arbitration(role string, id *gnmi_ext.Uint128) *gnmi_ext.Extension { + arb := &gnmi_ext.MasterArbitration{ + Role: &gnmi_ext.Role{Id: role}, + ElectionId: id, + } + ext := gnmi_ext.Extension_MasterArbitration{MasterArbitration: arb} + return &gnmi_ext.Extension{Ext: &ext} +} + +func electionID(high, low uint64) *gnmi_ext.Uint128 { + return &gnmi_ext.Uint128{High: high, Low: low} +} + +func TestArbitrationExt(t *testing.T) { + testCases := map[string]struct { + s string + ext *gnmi_ext.Extension + err error + }{ + "empty": {}, + "no_role": { + s: "1", + ext: arbitration("", electionID(0, 1)), + }, + "with_role": { + s: "admin:1", + ext: arbitration("admin", electionID(0, 1)), + }, + "large_no_role": { + s: "9223372036854775807", + ext: arbitration("", electionID(0, 9223372036854775807)), + }, + "large_with_role": { + s: "admin:18446744073709551615", + ext: arbitration("admin", electionID(0, 18446744073709551615)), + }, + "invalid": { + s: "cat", + err: fmt.Errorf("badly formed arbitration id (%s)", "cat"), + }, + "invalid_too_many_colons": { + s: "dog:1:2", + err: fmt.Errorf("badly formed arbitration id (%s)", "dog:1:2"), + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + ext, err := ArbitrationExt(tc.s) + if !test.DeepEqual(tc.ext, ext) { + t.Errorf("Expected %#v, got %#v", tc.ext, ext) + } + if !test.DeepEqual(tc.err, err) { + t.Errorf("Expected %v, got %v", tc.err, err) + } + }) + } +} diff --git a/forks/goarista/gnmi/client.go b/forks/goarista/gnmi/client.go new file mode 100644 index 0000000000000000000000000000000000000000..1a23050eb66ee850af78a25dcdb53ca2bc34c92f --- /dev/null +++ b/forks/goarista/gnmi/client.go @@ -0,0 +1,330 @@ +// Copyright (c) 2017 Arista Networks, Inc. +// Use of this source code is governed by the Apache License 2.0 +// that can be found in the COPYING file. + +package gnmi + +import ( + "context" + "crypto/tls" + "crypto/x509" + "flag" + "fmt" + "math" + "net" + "os" + + "io/ioutil" + "strings" + + "github.com/golang/protobuf/proto" + pb "github.com/openconfig/gnmi/proto/gnmi" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/encoding/gzip" + "google.golang.org/grpc/metadata" +) + +const ( + defaultPort = "6030" + // HostnameArg is the value to be replaced by the actual hostname + HostnameArg = "HOSTNAME" +) + +// PublishFunc is the method to publish responses +type PublishFunc func(addr string, message proto.Message) + +// ParseHostnames parses a comma-separated list of names and replaces HOSTNAME with the current +// hostname in it +func ParseHostnames(list string) ([]string, error) { + items := strings.Split(list, ",") + hostname, err := os.Hostname() + if err != nil { + return nil, err + } + names := make([]string, len(items)) + for i, name := range items { + if name == HostnameArg { + name = hostname + } + names[i] = name + } + return names, nil +} + +// Config is the gnmi.Client config +type Config struct { + Addr string + CAFile string + CertFile string + KeyFile string + Password string + Username string + TLS bool + Compression string + DialOptions []grpc.DialOption + Token string +} + +// SubscribeOptions is the gNMI subscription request options +type SubscribeOptions struct { + UpdatesOnly bool + Prefix string + Mode string + StreamMode string + SampleInterval uint64 + SuppressRedundant bool + HeartbeatInterval uint64 + Paths [][]string + Origin string + Target string +} + +// ParseFlags reads arguments from stdin and returns a populated Config object and a list of +// paths to subscribe to +func ParseFlags() (*Config, []string) { + // flags + var ( + addrsFlag = flag.String("addrs", "localhost:6030", + "Comma-separated list of addresses of OpenConfig gRPC servers. The address 'HOSTNAME' "+ + "is replaced by the current hostname.") + + caFileFlag = flag.String("cafile", "", + "Path to server TLS certificate file") + + certFileFlag = flag.String("certfile", "", + "Path to ciena TLS certificate file") + + keyFileFlag = flag.String("keyfile", "", + "Path to ciena TLS private key file") + + passwordFlag = flag.String("password", "", + "Password to authenticate with") + + usernameFlag = flag.String("username", "", + "Username to authenticate with") + + tlsFlag = flag.Bool("tls", false, + "Enable TLS") + + compressionFlag = flag.String("compression", "", + "Type of compression to use") + + subscribeFlag = flag.String("subscribe", "", + "Comma-separated list of paths to subscribe to upon connecting to the server") + + token = flag.String("token", "", + "Authentication token") + ) + flag.Parse() + cfg := &Config{ + Addr: *addrsFlag, + CAFile: *caFileFlag, + CertFile: *certFileFlag, + KeyFile: *keyFileFlag, + Password: *passwordFlag, + Username: *usernameFlag, + TLS: *tlsFlag, + Compression: *compressionFlag, + Token: *token, + } + subscriptions := strings.Split(*subscribeFlag, ",") + return cfg, subscriptions + +} + +// accessTokenCred implements credentials.PerRPCCredentials, the gRPC +// interface for credentials that need to attach security information +// to every RPC. +type accessTokenCred struct { + bearerToken string +} + +// newAccessTokenCredential constructs a new per-RPC credential from a token. +func newAccessTokenCredential(token string) credentials.PerRPCCredentials { + bearerFmt := "Bearer %s" + return &accessTokenCred{bearerToken: fmt.Sprintf(bearerFmt, token)} +} + +func (a *accessTokenCred) GetRequestMetadata(ctx context.Context, + uri ...string) (map[string]string, error) { + authHeader := "Authorization" + return map[string]string{ + authHeader: a.bearerToken, + }, nil +} + +func (a *accessTokenCred) RequireTransportSecurity() bool { return true } + +// DialContext connects to a gnmi service and returns a ciena +func DialContext(ctx context.Context, cfg *Config) (pb.GNMIClient, error) { + opts := append([]grpc.DialOption(nil), cfg.DialOptions...) + + switch cfg.Compression { + case "": + case "gzip": + opts = append(opts, grpc.WithDefaultCallOptions(grpc.UseCompressor(gzip.Name))) + default: + return nil, fmt.Errorf("unsupported compression option: %q", cfg.Compression) + } + + if cfg.TLS || cfg.CAFile != "" || cfg.CertFile != "" || cfg.Token != "" { + tlsConfig := &tls.Config{} + if cfg.CAFile != "" { + b, err := ioutil.ReadFile(cfg.CAFile) + if err != nil { + return nil, err + } + cp := x509.NewCertPool() + if !cp.AppendCertsFromPEM(b) { + return nil, fmt.Errorf("credentials: failed to append certificates") + } + tlsConfig.RootCAs = cp + } else { + tlsConfig.InsecureSkipVerify = true + } + if cfg.CertFile != "" { + if cfg.KeyFile == "" { + return nil, fmt.Errorf("please provide both -certfile and -keyfile") + } + cert, err := tls.LoadX509KeyPair(cfg.CertFile, cfg.KeyFile) + if err != nil { + return nil, err + } + tlsConfig.Certificates = []tls.Certificate{cert} + } + if cfg.Token != "" { + opts = append(opts, + grpc.WithPerRPCCredentials(newAccessTokenCredential(cfg.Token))) + } + opts = append(opts, grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig))) + } else { + opts = append(opts, grpc.WithInsecure()) + } + + dial := func(ctx context.Context, addrIn string) (conn net.Conn, err error) { + var network, addr string + + split := strings.Split(addrIn, "://") + if l := len(split); l == 2 { + network = split[0] + addr = split[1] + } else { + network = "tcp" + addr = split[0] + } + + conn, err = (&net.Dialer{}).DialContext(ctx, network, addr) + return + } + + opts = append(opts, + grpc.WithContextDialer(dial), + + // Allows received protobuf messages to be larger than 4MB + grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(math.MaxInt32)), + ) + + grpcconn, err := grpc.DialContext(ctx, cfg.Addr, opts...) + if err != nil { + return nil, fmt.Errorf("failed to dial: %s", err) + } + + return pb.NewGNMIClient(grpcconn), nil +} + +// Dial connects to a gnmi service and returns a ciena +func Dial(cfg *Config) (pb.GNMIClient, error) { + return DialContext(context.Background(), cfg) +} + +// NewContext returns a new context with username and password +// metadata if they are set in cfg. +func NewContext(ctx context.Context, cfg *Config) context.Context { + if cfg.Username != "" { + ctx = metadata.NewOutgoingContext(ctx, metadata.Pairs( + "username", cfg.Username, + "password", cfg.Password)) + } + return ctx +} + +// NewGetRequest returns a GetRequest for the given paths +func NewGetRequest(paths [][]string, origin string) (*pb.GetRequest, error) { + req := &pb.GetRequest{ + Path: make([]*pb.Path, len(paths)), + } + for i, p := range paths { + gnmiPath, err := ParseGNMIElements(p) + if err != nil { + return nil, err + } + req.Path[i] = gnmiPath + req.Path[i].Origin = origin + } + return req, nil +} + +// NewSubscribeRequest returns a SubscribeRequest for the given paths +func NewSubscribeRequest(subscribeOptions *SubscribeOptions) (*pb.SubscribeRequest, error) { + var mode pb.SubscriptionList_Mode + switch subscribeOptions.Mode { + case "once": + mode = pb.SubscriptionList_ONCE + case "poll": + mode = pb.SubscriptionList_POLL + case "": + fallthrough + case "stream": + mode = pb.SubscriptionList_STREAM + default: + return nil, fmt.Errorf("subscribe mode (%s) invalid", subscribeOptions.Mode) + } + + var streamMode pb.SubscriptionMode + switch subscribeOptions.StreamMode { + case "on_change": + streamMode = pb.SubscriptionMode_ON_CHANGE + case "sample": + streamMode = pb.SubscriptionMode_SAMPLE + case "": + fallthrough + case "target_defined": + streamMode = pb.SubscriptionMode_TARGET_DEFINED + default: + return nil, fmt.Errorf("subscribe stream mode (%s) invalid", subscribeOptions.StreamMode) + } + + prefixPath, err := ParseGNMIElements(SplitPath(subscribeOptions.Prefix)) + if err != nil { + return nil, err + } + subList := &pb.SubscriptionList{ + Subscription: make([]*pb.Subscription, len(subscribeOptions.Paths)), + Mode: mode, + UpdatesOnly: subscribeOptions.UpdatesOnly, + Prefix: prefixPath, + } + if subscribeOptions.Target != "" { + if subList.Prefix == nil { + subList.Prefix = &pb.Path{} + } + subList.Prefix.Target = subscribeOptions.Target + } + for i, p := range subscribeOptions.Paths { + gnmiPath, err := ParseGNMIElements(p) + if err != nil { + return nil, err + } + gnmiPath.Origin = subscribeOptions.Origin + subList.Subscription[i] = &pb.Subscription{ + Path: gnmiPath, + Mode: streamMode, + SampleInterval: subscribeOptions.SampleInterval, + SuppressRedundant: subscribeOptions.SuppressRedundant, + HeartbeatInterval: subscribeOptions.HeartbeatInterval, + } + } + return &pb.SubscribeRequest{Request: &pb.SubscribeRequest_Subscribe{ + Subscribe: subList}}, nil +} diff --git a/forks/goarista/gnmi/json.go b/forks/goarista/gnmi/json.go new file mode 100644 index 0000000000000000000000000000000000000000..30aacd3df8239f39dd1af90a4fca9582cff6de1c --- /dev/null +++ b/forks/goarista/gnmi/json.go @@ -0,0 +1,35 @@ +// Copyright (c) 2017 Arista Networks, Inc. +// Use of this source code is governed by the Apache License 2.0 +// that can be found in the COPYING file. + +package gnmi + +import ( + "github.com/openconfig/gnmi/proto/gnmi" +) + +// NotificationToMap converts a Notification into a map[string]interface{} +func NotificationToMap(notif *gnmi.Notification) (map[string]interface{}, error) { + m := make(map[string]interface{}, 1) + m["timestamp"] = notif.Timestamp + m["path"] = StrPath(notif.Prefix) + if len(notif.Update) != 0 { + updates := make(map[string]interface{}, len(notif.Update)) + var err error + for _, update := range notif.Update { + updates[StrPath(update.Path)] = StrUpdateVal(update) + if err != nil { + return nil, err + } + } + m["updates"] = updates + } + if len(notif.Delete) != 0 { + deletes := make([]string, len(notif.Delete)) + for i, del := range notif.Delete { + deletes[i] = StrPath(del) + } + m["deletes"] = deletes + } + return m, nil +} diff --git a/forks/goarista/gnmi/operation.go b/forks/goarista/gnmi/operation.go new file mode 100644 index 0000000000000000000000000000000000000000..ef506ac5e99e824fc4adc4cb34805e2b4ed27dd1 --- /dev/null +++ b/forks/goarista/gnmi/operation.go @@ -0,0 +1,541 @@ +// Copyright (c) 2017 Arista Networks, Inc. +// Use of this source code is governed by the Apache License 2.0 +// that can be found in the COPYING file. + +package gnmi + +import ( + "bufio" + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "io/ioutil" + "math" + "os" + "path" + "strconv" + "strings" + "time" + + pb "github.com/openconfig/gnmi/proto/gnmi" + "github.com/openconfig/gnmi/proto/gnmi_ext" + "google.golang.org/grpc/codes" +) + +// GetWithRequest takes a fully formed GetRequest, performs the Get, +// and displays any response. +func GetWithRequest(ctx context.Context, client pb.GNMIClient, + req *pb.GetRequest) error { + resp, err := client.Get(ctx, req) + if err != nil { + return err + } + for _, notif := range resp.Notification { + prefix := StrPath(notif.Prefix) + for _, update := range notif.Update { + fmt.Printf("%s:\n", path.Join(prefix, StrPath(update.Path))) + fmt.Println(StrUpdateVal(update)) + } + } + return nil +} + +// Get sends a GetRequest to the given ciena. +func Get(ctx context.Context, client pb.GNMIClient, paths [][]string, + origin string) error { + req, err := NewGetRequest(paths, origin) + if err != nil { + return err + } + return GetWithRequest(ctx, client, req) +} + +// Capabilities retuns the capabilities of the ciena. +func Capabilities(ctx context.Context, client pb.GNMIClient) error { + resp, err := client.Capabilities(ctx, &pb.CapabilityRequest{}) + if err != nil { + return err + } + fmt.Printf("Version: %s\n", resp.GNMIVersion) + for _, mod := range resp.SupportedModels { + fmt.Printf("SupportedModel: %s\n", mod) + } + for _, enc := range resp.SupportedEncodings { + fmt.Printf("SupportedEncoding: %s\n", enc) + } + return nil +} + +// val may be a path to a file or it may be json. First see if it is a +// file, if so return its contents, otherwise return val +func extractJSON(val string) []byte { + if jsonBytes, err := ioutil.ReadFile(val); err == nil { + return jsonBytes + } + // Best effort check if the value might a string literal, in which + // case wrap it in quotes. This is to allow a user to do: + // gnmi update ../hostname host1234 + // gnmi update ../description 'This is a description' + // instead of forcing them to quote the string: + // gnmi update ../hostname '"host1234"' + // gnmi update ../description '"This is a description"' + maybeUnquotedStringLiteral := func(s string) bool { + if s == "true" || s == "false" || s == "null" || // JSON reserved words + strings.ContainsAny(s, `"'{}[]`) { // Already quoted or is a JSON object or array + return false + } else if _, err := strconv.ParseInt(s, 0, 32); err == nil { + // Integer. Using byte size of 32 because larger integer + // types are supposed to be sent as strings in JSON. + return false + } else if _, err := strconv.ParseFloat(s, 64); err == nil { + // Float + return false + } + + return true + } + if maybeUnquotedStringLiteral(val) { + out := make([]byte, len(val)+2) + out[0] = '"' + copy(out[1:], val) + out[len(out)-1] = '"' + return out + } + return []byte(val) +} + +// StrUpdateVal will return a string representing the value within the supplied update +func StrUpdateVal(u *pb.Update) string { + if u.Value != nil { + // Backwards compatibility with pre-v0.4 gnmi + switch u.Value.Type { + case pb.Encoding_JSON, pb.Encoding_JSON_IETF: + return strJSON(u.Value.Value) + case pb.Encoding_BYTES, pb.Encoding_PROTO: + return base64.StdEncoding.EncodeToString(u.Value.Value) + case pb.Encoding_ASCII: + return string(u.Value.Value) + default: + return string(u.Value.Value) + } + } + return StrVal(u.Val) +} + +// StrVal will return a string representing the supplied value +func StrVal(val *pb.TypedValue) string { + switch v := val.GetValue().(type) { + case *pb.TypedValue_StringVal: + return v.StringVal + case *pb.TypedValue_JsonIetfVal: + return strJSON(v.JsonIetfVal) + case *pb.TypedValue_JsonVal: + return strJSON(v.JsonVal) + case *pb.TypedValue_IntVal: + return strconv.FormatInt(v.IntVal, 10) + case *pb.TypedValue_UintVal: + return strconv.FormatUint(v.UintVal, 10) + case *pb.TypedValue_BoolVal: + return strconv.FormatBool(v.BoolVal) + case *pb.TypedValue_BytesVal: + return base64.StdEncoding.EncodeToString(v.BytesVal) + case *pb.TypedValue_DecimalVal: + return strDecimal64(v.DecimalVal) + case *pb.TypedValue_FloatVal: + return strconv.FormatFloat(float64(v.FloatVal), 'g', -1, 32) + case *pb.TypedValue_LeaflistVal: + return strLeaflist(v.LeaflistVal) + case *pb.TypedValue_AsciiVal: + return v.AsciiVal + case *pb.TypedValue_AnyVal: + return v.AnyVal.String() + case *pb.TypedValue_ProtoBytes: + return base64.StdEncoding.EncodeToString(v.ProtoBytes) + default: + panic(v) + } +} + +func strJSON(inJSON []byte) string { + var ( + out bytes.Buffer + err error + ) + // Check for ',' as simple heuristic on whether to expand JSON + // onto multiple lines, or compact it to a single line. + if bytes.Contains(inJSON, []byte{','}) { + err = json.Indent(&out, inJSON, "", " ") + } else { + err = json.Compact(&out, inJSON) + } + if err != nil { + return fmt.Sprintf("(error unmarshalling json: %s)\n", err) + string(inJSON) + } + return out.String() +} + +func strDecimal64(d *pb.Decimal64) string { + var i, frac int64 + if d.Precision > 0 { + div := int64(10) + it := d.Precision - 1 + for it > 0 { + div *= 10 + it-- + } + i = d.Digits / div + frac = d.Digits % div + } else { + i = d.Digits + } + if frac < 0 { + frac = -frac + } + return fmt.Sprintf("%d.%d", i, frac) +} + +// strLeafList builds a human-readable form of a leaf-list. e.g. [1, 2, 3] or [a, b, c] +func strLeaflist(v *pb.ScalarArray) string { + var b strings.Builder + b.WriteByte('[') + + for i, elm := range v.Element { + b.WriteString(StrVal(elm)) + if i < len(v.Element)-1 { + b.WriteString(", ") + } + } + + b.WriteByte(']') + return b.String() +} + +// TypedValue marshals an interface into a gNMI TypedValue value +func TypedValue(val interface{}) *pb.TypedValue { + // TODO: handle more types: + // float64 + // maps + // key.Key + // key.Map + // ... etc + switch v := val.(type) { + case string: + return &pb.TypedValue{Value: &pb.TypedValue_StringVal{StringVal: v}} + case int: + return &pb.TypedValue{Value: &pb.TypedValue_IntVal{IntVal: int64(v)}} + case int8: + return &pb.TypedValue{Value: &pb.TypedValue_IntVal{IntVal: int64(v)}} + case int16: + return &pb.TypedValue{Value: &pb.TypedValue_IntVal{IntVal: int64(v)}} + case int32: + return &pb.TypedValue{Value: &pb.TypedValue_IntVal{IntVal: int64(v)}} + case int64: + return &pb.TypedValue{Value: &pb.TypedValue_IntVal{IntVal: v}} + case uint: + return &pb.TypedValue{Value: &pb.TypedValue_UintVal{UintVal: uint64(v)}} + case uint8: + return &pb.TypedValue{Value: &pb.TypedValue_UintVal{UintVal: uint64(v)}} + case uint16: + return &pb.TypedValue{Value: &pb.TypedValue_UintVal{UintVal: uint64(v)}} + case uint32: + return &pb.TypedValue{Value: &pb.TypedValue_UintVal{UintVal: uint64(v)}} + case uint64: + return &pb.TypedValue{Value: &pb.TypedValue_UintVal{UintVal: v}} + case bool: + return &pb.TypedValue{Value: &pb.TypedValue_BoolVal{BoolVal: v}} + case float32: + return &pb.TypedValue{Value: &pb.TypedValue_FloatVal{FloatVal: v}} + case []interface{}: + gnmiElems := make([]*pb.TypedValue, len(v)) + for i, elem := range v { + gnmiElems[i] = TypedValue(elem) + } + return &pb.TypedValue{ + Value: &pb.TypedValue_LeaflistVal{ + LeaflistVal: &pb.ScalarArray{ + Element: gnmiElems, + }}} + default: + panic(fmt.Sprintf("unexpected type %T for value %v", val, val)) + } +} + +// ExtractValue pulls a value out of a gNMI Update, parsing JSON if present. +// Possible return types: +// string +// int64 +// uint64 +// bool +// []byte +// float32 +// *gnmi.Decimal64 +// json.Number +// *any.Any +// []interface{} +// map[string]interface{} +func ExtractValue(update *pb.Update) (interface{}, error) { + var i interface{} + var err error + if update == nil { + return nil, fmt.Errorf("empty update") + } + if update.Val != nil { + i, err = extractValueV04(update.Val) + } else if update.Value != nil { + i, err = extractValueV03(update.Value) + } + return i, err +} + +func extractValueV04(val *pb.TypedValue) (interface{}, error) { + switch v := val.Value.(type) { + case *pb.TypedValue_StringVal: + return v.StringVal, nil + case *pb.TypedValue_IntVal: + return v.IntVal, nil + case *pb.TypedValue_UintVal: + return v.UintVal, nil + case *pb.TypedValue_BoolVal: + return v.BoolVal, nil + case *pb.TypedValue_BytesVal: + return v.BytesVal, nil + case *pb.TypedValue_FloatVal: + return v.FloatVal, nil + case *pb.TypedValue_DecimalVal: + return v.DecimalVal, nil + case *pb.TypedValue_LeaflistVal: + elementList := v.LeaflistVal.Element + l := make([]interface{}, len(elementList)) + for i, element := range elementList { + el, err := extractValueV04(element) + if err != nil { + return nil, err + } + l[i] = el + } + return l, nil + case *pb.TypedValue_AnyVal: + return v.AnyVal, nil + case *pb.TypedValue_JsonVal: + return decode(v.JsonVal) + case *pb.TypedValue_JsonIetfVal: + return decode(v.JsonIetfVal) + case *pb.TypedValue_AsciiVal: + return v.AsciiVal, nil + case *pb.TypedValue_ProtoBytes: + return v.ProtoBytes, nil + } + return nil, fmt.Errorf("unhandled type of value %v", val.GetValue()) +} + +func extractValueV03(val *pb.Value) (interface{}, error) { + switch val.Type { + case pb.Encoding_JSON, pb.Encoding_JSON_IETF: + return decode(val.Value) + case pb.Encoding_BYTES, pb.Encoding_PROTO: + return val.Value, nil + case pb.Encoding_ASCII: + return string(val.Value), nil + } + return nil, fmt.Errorf("unhandled type of value %v", val.GetValue()) +} + +func decode(byteArr []byte) (interface{}, error) { + decoder := json.NewDecoder(bytes.NewReader(byteArr)) + decoder.UseNumber() + var value interface{} + err := decoder.Decode(&value) + return value, err +} + +// DecimalToFloat converts a gNMI Decimal64 to a float64 +func DecimalToFloat(dec *pb.Decimal64) float64 { + return float64(dec.Digits) / math.Pow10(int(dec.Precision)) +} + +func update(p *pb.Path, val string) (*pb.Update, error) { + var v *pb.TypedValue + switch p.Origin { + case "": + v = &pb.TypedValue{ + Value: &pb.TypedValue_JsonIetfVal{JsonIetfVal: extractJSON(val)}} + case "eos_native": + v = &pb.TypedValue{ + Value: &pb.TypedValue_JsonVal{JsonVal: extractJSON(val)}} + case "cli", "test-regen-cli": + v = &pb.TypedValue{ + Value: &pb.TypedValue_AsciiVal{AsciiVal: val}} + case "p4_config": + b, err := ioutil.ReadFile(val) + if err != nil { + return nil, err + } + v = &pb.TypedValue{ + Value: &pb.TypedValue_ProtoBytes{ProtoBytes: b}} + default: + return nil, fmt.Errorf("unexpected origin: %q", p.Origin) + } + + return &pb.Update{Path: p, Val: v}, nil +} + +// Operation describes an gNMI operation. +type Operation struct { + Type string + Origin string + Target string + Path []string + Val string +} + +func newSetRequest(setOps []*Operation, exts ...*gnmi_ext.Extension) (*pb.SetRequest, error) { + req := &pb.SetRequest{} + for _, op := range setOps { + p, err := ParseGNMIElements(op.Path) + if err != nil { + return nil, err + } + p.Origin = op.Origin + + // Target must apply to the entire SetRequest. + if op.Target != "" { + req.Prefix = &pb.Path{ + Target: op.Target, + } + } + + switch op.Type { + case "delete": + req.Delete = append(req.Delete, p) + case "update": + u, err := update(p, op.Val) + if err != nil { + return nil, err + } + req.Update = append(req.Update, u) + case "replace": + u, err := update(p, op.Val) + if err != nil { + return nil, err + } + req.Replace = append(req.Replace, u) + } + } + for _, ext := range exts { + req.Extension = append(req.Extension, ext) + } + return req, nil +} + +// Set sends a SetRequest to the given ciena. +func Set(ctx context.Context, client pb.GNMIClient, setOps []*Operation, + exts ...*gnmi_ext.Extension) error { + req, err := newSetRequest(setOps, exts...) + if err != nil { + return err + } + resp, err := client.Set(ctx, req) + if err != nil { + return err + } + if resp.Message != nil && codes.Code(resp.Message.Code) != codes.OK { + return errors.New(resp.Message.Message) + } + return nil +} + +// Subscribe sends a SubscribeRequest to the given ciena. +// Deprecated: Use SubscribeErr instead. +func Subscribe(ctx context.Context, client pb.GNMIClient, subscribeOptions *SubscribeOptions, + respChan chan<- *pb.SubscribeResponse, errChan chan<- error) { + defer close(errChan) + if err := SubscribeErr(ctx, client, subscribeOptions, respChan); err != nil { + errChan <- err + } +} + +// SubscribeErr makes a gNMI.Subscribe call and writes the responses +// to the respChan. Before returning respChan will be closed. +func SubscribeErr(ctx context.Context, client pb.GNMIClient, subscribeOptions *SubscribeOptions, + respChan chan<- *pb.SubscribeResponse) error { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + defer close(respChan) + + stream, err := client.Subscribe(ctx) + if err != nil { + return err + } + req, err := NewSubscribeRequest(subscribeOptions) + if err != nil { + return err + } + if err := stream.Send(req); err != nil { + return err + } + + for { + resp, err := stream.Recv() + if err != nil { + if err == io.EOF { + return nil + } + return err + } + respChan <- resp + + // For POLL subscriptions, initiate a poll request by pressing ENTER + if subscribeOptions.Mode == "poll" { + switch resp.Response.(type) { + case *pb.SubscribeResponse_SyncResponse: + fmt.Print("Press ENTER to send a poll request: ") + reader := bufio.NewReader(os.Stdin) + reader.ReadString('\n') + + pollReq := &pb.SubscribeRequest{ + Request: &pb.SubscribeRequest_Poll{ + Poll: &pb.Poll{}, + }, + } + if err := stream.Send(pollReq); err != nil { + return err + } + } + } + } +} + +// LogSubscribeResponse logs update responses to stderr. +func LogSubscribeResponse(response *pb.SubscribeResponse) error { + switch resp := response.Response.(type) { + case *pb.SubscribeResponse_Error: + return errors.New(resp.Error.Message) + case *pb.SubscribeResponse_SyncResponse: + if !resp.SyncResponse { + return errors.New("initial sync failed") + } + case *pb.SubscribeResponse_Update: + t := time.Unix(0, resp.Update.Timestamp).UTC() + prefix := StrPath(resp.Update.Prefix) + var target string + if t := resp.Update.Prefix.GetTarget(); t != "" { + target = "(" + t + ") " + } + for _, update := range resp.Update.Update { + fmt.Printf("[%s] %s%s = %s\n", t.Format(time.RFC3339Nano), + target, + path.Join(prefix, StrPath(update.Path)), + StrUpdateVal(update)) + } + for _, del := range resp.Update.Delete { + fmt.Printf("[%s] %sDeleted %s\n", t.Format(time.RFC3339Nano), + target, + path.Join(prefix, StrPath(del))) + } + } + return nil +} diff --git a/forks/goarista/gnmi/operation_test.go b/forks/goarista/gnmi/operation_test.go new file mode 100644 index 0000000000000000000000000000000000000000..fd575d10aa5ea2766b9da07a4c4865ee101d923c --- /dev/null +++ b/forks/goarista/gnmi/operation_test.go @@ -0,0 +1,423 @@ +// Copyright (c) 2017 Arista Networks, Inc. +// Use of this source code is governed by the Apache License 2.0 +// that can be found in the COPYING file. + +package gnmi + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "os" + "testing" + + "github.com/aristanetworks/goarista/test" + "github.com/golang/protobuf/proto" + "github.com/golang/protobuf/ptypes/any" + + pb "github.com/openconfig/gnmi/proto/gnmi" +) + +func TestNewSetRequest(t *testing.T) { + pathFoo := &pb.Path{ + Element: []string{"foo"}, + Elem: []*pb.PathElem{{Name: "foo"}}, + } + pathCli := &pb.Path{ + Origin: "cli", + } + pathP4 := &pb.Path{ + Origin: "p4_config", + } + + p4FileContent := "p4_config test" + p4TestFile, err := ioutil.TempFile("", "p4TestFile") + if err != nil { + t.Errorf("cannot create test file for p4_config") + } + p4Filename := p4TestFile.Name() + + defer os.Remove(p4Filename) + + if _, err := p4TestFile.WriteString(p4FileContent); err != nil { + t.Errorf("cannot write test file for p4_config") + } + p4TestFile.Close() + + testCases := map[string]struct { + setOps []*Operation + exp pb.SetRequest + }{ + "delete": { + setOps: []*Operation{{Type: "delete", Path: []string{"foo"}}}, + exp: pb.SetRequest{Delete: []*pb.Path{pathFoo}}, + }, + "update": { + setOps: []*Operation{{Type: "update", Path: []string{"foo"}, Val: "true"}}, + exp: pb.SetRequest{ + Update: []*pb.Update{{ + Path: pathFoo, + Val: &pb.TypedValue{ + Value: &pb.TypedValue_JsonIetfVal{JsonIetfVal: []byte("true")}}, + }}, + }, + }, + "replace": { + setOps: []*Operation{{Type: "replace", Path: []string{"foo"}, Val: "true"}}, + exp: pb.SetRequest{ + Replace: []*pb.Update{{ + Path: pathFoo, + Val: &pb.TypedValue{ + Value: &pb.TypedValue_JsonIetfVal{JsonIetfVal: []byte("true")}}, + }}, + }, + }, + "cli-replace": { + setOps: []*Operation{{Type: "replace", Origin: "cli", + Val: "hostname foo\nip routing"}}, + exp: pb.SetRequest{ + Replace: []*pb.Update{{ + Path: pathCli, + Val: &pb.TypedValue{ + Value: &pb.TypedValue_AsciiVal{AsciiVal: "hostname foo\nip routing"}}, + }}, + }, + }, + "p4_config": { + setOps: []*Operation{{Type: "replace", Origin: "p4_config", + Val: p4Filename}}, + exp: pb.SetRequest{ + Replace: []*pb.Update{{ + Path: pathP4, + Val: &pb.TypedValue{ + Value: &pb.TypedValue_ProtoBytes{ProtoBytes: []byte(p4FileContent)}}, + }}, + }, + }, + "target": { + setOps: []*Operation{{Type: "replace", Target: "JPE1234567", + Path: []string{"foo"}, Val: "true"}}, + exp: pb.SetRequest{ + Prefix: &pb.Path{Target: "JPE1234567"}, + Replace: []*pb.Update{{ + Path: pathFoo, + Val: &pb.TypedValue{ + Value: &pb.TypedValue_JsonIetfVal{JsonIetfVal: []byte("true")}}, + }}, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, err := newSetRequest(tc.setOps) + if err != nil { + t.Fatal(err) + } + if diff := test.Diff(tc.exp, *got); diff != "" { + t.Errorf("unexpected diff: %s", diff) + } + }) + } +} + +func TestStrUpdateVal(t *testing.T) { + anyBytes, err := proto.Marshal(&pb.ModelData{Name: "foobar"}) + if err != nil { + t.Fatal(err) + } + anyMessage := &any.Any{TypeUrl: "gnmi/ModelData", Value: anyBytes} + anyString := proto.CompactTextString(anyMessage) + + for name, tc := range map[string]struct { + update *pb.Update + exp string + }{ + "JSON Value": { + update: &pb.Update{ + Value: &pb.Value{ + Value: []byte(`{"foo":"bar"}`), + Type: pb.Encoding_JSON}}, + exp: `{"foo":"bar"}`, + }, + "JSON_IETF Value": { + update: &pb.Update{ + Value: &pb.Value{ + Value: []byte(`{"foo":"bar"}`), + Type: pb.Encoding_JSON_IETF}}, + exp: `{"foo":"bar"}`, + }, + "BYTES Value": { + update: &pb.Update{ + Value: &pb.Value{ + Value: []byte{0xde, 0xad}, + Type: pb.Encoding_BYTES}}, + exp: "3q0=", + }, + "PROTO Value": { + update: &pb.Update{ + Value: &pb.Value{ + Value: []byte{0xde, 0xad}, + Type: pb.Encoding_PROTO}}, + exp: "3q0=", + }, + "ASCII Value": { + update: &pb.Update{ + Value: &pb.Value{ + Value: []byte("foobar"), + Type: pb.Encoding_ASCII}}, + exp: "foobar", + }, + "INVALID Value": { + update: &pb.Update{ + Value: &pb.Value{ + Value: []byte("foobar"), + Type: pb.Encoding(42)}}, + exp: "foobar", + }, + "StringVal": { + update: &pb.Update{Val: &pb.TypedValue{ + Value: &pb.TypedValue_StringVal{StringVal: "foobar"}}}, + exp: "foobar", + }, + "IntVal": { + update: &pb.Update{Val: &pb.TypedValue{ + Value: &pb.TypedValue_IntVal{IntVal: -42}}}, + exp: "-42", + }, + "UintVal": { + update: &pb.Update{Val: &pb.TypedValue{ + Value: &pb.TypedValue_UintVal{UintVal: 42}}}, + exp: "42", + }, + "BoolVal": { + update: &pb.Update{Val: &pb.TypedValue{ + Value: &pb.TypedValue_BoolVal{BoolVal: true}}}, + exp: "true", + }, + "BytesVal": { + update: &pb.Update{Val: &pb.TypedValue{ + Value: &pb.TypedValue_BytesVal{BytesVal: []byte{0xde, 0xad}}}}, + exp: "3q0=", + }, + "FloatVal": { + update: &pb.Update{Val: &pb.TypedValue{ + Value: &pb.TypedValue_FloatVal{FloatVal: 3.14}}}, + exp: "3.14", + }, + "DecimalVal": { + update: &pb.Update{Val: &pb.TypedValue{ + Value: &pb.TypedValue_DecimalVal{ + DecimalVal: &pb.Decimal64{Digits: 314, Precision: 2}, + }}}, + exp: "3.14", + }, + "LeafListVal": { + update: &pb.Update{Val: &pb.TypedValue{ + Value: &pb.TypedValue_LeaflistVal{ + LeaflistVal: &pb.ScalarArray{Element: []*pb.TypedValue{ + {Value: &pb.TypedValue_BoolVal{BoolVal: true}}, + {Value: &pb.TypedValue_AsciiVal{AsciiVal: "foobar"}}, + }}, + }}}, + exp: "[true, foobar]", + }, + "AnyVal": { + update: &pb.Update{Val: &pb.TypedValue{ + Value: &pb.TypedValue_AnyVal{AnyVal: anyMessage}}}, + exp: anyString, + }, + "JsonVal": { + update: &pb.Update{Val: &pb.TypedValue{ + Value: &pb.TypedValue_JsonVal{JsonVal: []byte(`{"foo":"bar"}`)}}}, + exp: `{"foo":"bar"}`, + }, + "JsonVal_complex": { + update: &pb.Update{Val: &pb.TypedValue{ + Value: &pb.TypedValue_JsonVal{JsonVal: []byte(`{"foo":"bar","baz":"qux"}`)}}}, + exp: `{ + "foo": "bar", + "baz": "qux" +}`, + }, + "JsonIetfVal": { + update: &pb.Update{Val: &pb.TypedValue{ + Value: &pb.TypedValue_JsonIetfVal{JsonIetfVal: []byte(`{"foo":"bar"}`)}}}, + exp: `{"foo":"bar"}`, + }, + "AsciiVal": { + update: &pb.Update{Val: &pb.TypedValue{ + Value: &pb.TypedValue_AsciiVal{AsciiVal: "foobar"}}}, + exp: "foobar", + }, + "ProtoBytes": { + update: &pb.Update{Val: &pb.TypedValue{ + Value: &pb.TypedValue_ProtoBytes{ProtoBytes: anyBytes}}}, + exp: "CgZmb29iYXI=", + }, + } { + t.Run(name, func(t *testing.T) { + got := StrUpdateVal(tc.update) + if got != tc.exp { + t.Errorf("Expected: %q Got: %q", tc.exp, got) + } + }) + } +} + +func TestTypedValue(t *testing.T) { + for tname, tcase := range map[string]struct { + in interface{} + exp *pb.TypedValue + }{ + "string": { + in: "foo", + exp: &pb.TypedValue{Value: &pb.TypedValue_StringVal{StringVal: "foo"}}, + }, + "int": { + in: 42, + exp: &pb.TypedValue{Value: &pb.TypedValue_IntVal{IntVal: 42}}, + }, + "int64": { + in: int64(42), + exp: &pb.TypedValue{Value: &pb.TypedValue_IntVal{IntVal: 42}}, + }, + "uint": { + in: uint(42), + exp: &pb.TypedValue{Value: &pb.TypedValue_UintVal{UintVal: 42}}, + }, + "bool": { + in: true, + exp: &pb.TypedValue{Value: &pb.TypedValue_BoolVal{BoolVal: true}}, + }, + "slice": { + in: []interface{}{"foo", 1, uint(2), true}, + exp: &pb.TypedValue{Value: &pb.TypedValue_LeaflistVal{LeaflistVal: &pb.ScalarArray{ + Element: []*pb.TypedValue{ + {Value: &pb.TypedValue_StringVal{StringVal: "foo"}}, + {Value: &pb.TypedValue_IntVal{IntVal: 1}}, + {Value: &pb.TypedValue_UintVal{UintVal: 2}}, + {Value: &pb.TypedValue_BoolVal{BoolVal: true}}, + }}}}, + }, + } { + t.Run(tname, func(t *testing.T) { + if got := TypedValue(tcase.in); !test.DeepEqual(got, tcase.exp) { + t.Errorf("Expected: %q Got: %q", tcase.exp, got) + } + }) + } +} + +func TestExtractJSON(t *testing.T) { + jsonFile, err := ioutil.TempFile("", "extractJSON") + if err != nil { + t.Fatal(err) + } + defer os.Remove(jsonFile.Name()) + if _, err := jsonFile.Write([]byte(`"jsonFile"`)); err != nil { + jsonFile.Close() + t.Fatal(err) + } + if err := jsonFile.Close(); err != nil { + t.Fatal(err) + } + + for val, exp := range map[string][]byte{ + jsonFile.Name(): []byte(`"jsonFile"`), + "foobar": []byte(`"foobar"`), + `"foobar"`: []byte(`"foobar"`), + "Val: true": []byte(`"Val: true"`), + "host42": []byte(`"host42"`), + "42": []byte("42"), + "-123.43": []byte("-123.43"), + "0xFFFF": []byte("0xFFFF"), + // Int larger than can fit in 32 bits should be quoted + "0x8000000000": []byte(`"0x8000000000"`), + "-0x8000000000": []byte(`"-0x8000000000"`), + "true": []byte("true"), + "false": []byte("false"), + "null": []byte("null"), + "{true: 42}": []byte("{true: 42}"), + "[]": []byte("[]"), + } { + t.Run(val, func(t *testing.T) { + got := extractJSON(val) + if !bytes.Equal(exp, got) { + t.Errorf("Unexpected diff. Expected: %q Got: %q", exp, got) + } + }) + } +} + +func TestExtractValue(t *testing.T) { + cases := []struct { + in *pb.Update + exp interface{} + }{{ + in: &pb.Update{Val: &pb.TypedValue{ + Value: &pb.TypedValue_StringVal{StringVal: "foo"}}}, + exp: "foo", + }, { + in: &pb.Update{Val: &pb.TypedValue{ + Value: &pb.TypedValue_IntVal{IntVal: 123}}}, + exp: int64(123), + }, { + in: &pb.Update{Val: &pb.TypedValue{ + Value: &pb.TypedValue_UintVal{UintVal: 123}}}, + exp: uint64(123), + }, { + in: &pb.Update{Val: &pb.TypedValue{ + Value: &pb.TypedValue_BoolVal{BoolVal: true}}}, + exp: true, + }, { + in: &pb.Update{Val: &pb.TypedValue{ + Value: &pb.TypedValue_BytesVal{BytesVal: []byte{0xde, 0xad}}}}, + exp: []byte{0xde, 0xad}, + }, { + in: &pb.Update{Val: &pb.TypedValue{ + Value: &pb.TypedValue_FloatVal{FloatVal: -12.34}}}, + exp: float32(-12.34), + }, { + in: &pb.Update{Val: &pb.TypedValue{ + Value: &pb.TypedValue_DecimalVal{DecimalVal: &pb.Decimal64{ + Digits: -1234, Precision: 2}}}}, + exp: &pb.Decimal64{Digits: -1234, Precision: 2}, + }, { + in: &pb.Update{Val: &pb.TypedValue{ + Value: &pb.TypedValue_LeaflistVal{LeaflistVal: &pb.ScalarArray{ + Element: []*pb.TypedValue{ + {Value: &pb.TypedValue_StringVal{StringVal: "foo"}}, + {Value: &pb.TypedValue_IntVal{IntVal: 123}}}}}}}, + exp: []interface{}{"foo", int64(123)}, + }, { + in: &pb.Update{Val: &pb.TypedValue{ + Value: &pb.TypedValue_JsonVal{JsonVal: []byte(`12.34`)}}}, + exp: json.Number("12.34"), + }, { + in: &pb.Update{Val: &pb.TypedValue{ + Value: &pb.TypedValue_JsonVal{JsonVal: []byte(`[12.34, 123, "foo"]`)}}}, + exp: []interface{}{json.Number("12.34"), json.Number("123"), "foo"}, + }, { + in: &pb.Update{Val: &pb.TypedValue{ + Value: &pb.TypedValue_JsonVal{JsonVal: []byte(`{"foo":"bar"}`)}}}, + exp: map[string]interface{}{"foo": "bar"}, + }, { + in: &pb.Update{Val: &pb.TypedValue{ + Value: &pb.TypedValue_JsonVal{JsonVal: []byte(`{"foo":45.67}`)}}}, + exp: map[string]interface{}{"foo": json.Number("45.67")}, + }, { + in: &pb.Update{Val: &pb.TypedValue{ + Value: &pb.TypedValue_JsonIetfVal{JsonIetfVal: []byte(`{"foo":"bar"}`)}}}, + exp: map[string]interface{}{"foo": "bar"}, + }} + for _, tc := range cases { + out, err := ExtractValue(tc.in) + if err != nil { + t.Errorf(err.Error()) + } + if !test.DeepEqual(tc.exp, out) { + t.Errorf("Extracted value is incorrect. Expected %+v, got %+v", tc.exp, out) + } + } +} diff --git a/forks/goarista/gnmi/path.go b/forks/goarista/gnmi/path.go new file mode 100644 index 0000000000000000000000000000000000000000..00280a8fc5924785e036e8daf7e9e187ec8a0406 --- /dev/null +++ b/forks/goarista/gnmi/path.go @@ -0,0 +1,251 @@ +// Copyright (c) 2017 Arista Networks, Inc. +// Use of this source code is governed by the Apache License 2.0 +// that can be found in the COPYING file. + +package gnmi + +import ( + "fmt" + "sort" + "strings" + + pb "github.com/openconfig/gnmi/proto/gnmi" +) + +// nextTokenIndex returns the end index of the first token. +func nextTokenIndex(path string) int { + var inBrackets bool + var escape bool + for i, c := range path { + switch c { + case '[': + inBrackets = true + escape = false + case ']': + if !escape { + inBrackets = false + } + escape = false + case '\\': + escape = !escape + case '/': + if !inBrackets && !escape { + return i + } + escape = false + default: + escape = false + } + } + return len(path) +} + +// SplitPath splits a gnmi path according to the spec. See +// https://github.com/openconfig/reference/blob/master/rpc/gnmi/gnmi-path-conventions.md +// No validation is done. Behavior is undefined if path is an invalid +// gnmi path. TODO: Do validation? +func SplitPath(path string) []string { + var result []string + if len(path) > 0 && path[0] == '/' { + path = path[1:] + } + for len(path) > 0 { + i := nextTokenIndex(path) + result = append(result, path[:i]) + path = path[i:] + if len(path) > 0 && path[0] == '/' { + path = path[1:] + } + } + return result +} + +// SplitPaths splits multiple gnmi paths +func SplitPaths(paths []string) [][]string { + out := make([][]string, len(paths)) + for i, path := range paths { + out[i] = SplitPath(path) + } + return out +} + +// StrPath builds a human-readable form of a gnmi path. +// e.g. /a/b/c[e=f] +func StrPath(path *pb.Path) string { + if path == nil { + return "/" + } else if len(path.Elem) != 0 { + return strPathV04(path) + } else if len(path.Element) != 0 { + return strPathV03(path) + } + return "/" +} + +// strPathV04 handles the v0.4 gnmi and later path.Elem member. +func strPathV04(path *pb.Path) string { + b := &strings.Builder{} + for _, elm := range path.Elem { + b.WriteRune('/') + writeSafeString(b, elm.Name, '/') + if len(elm.Key) > 0 { + // Sort the keys so that they print in a conistent + // order. We don't have the YANG AST information, so the + // best we can do is sort them alphabetically. + keys := make([]string, 0, len(elm.Key)) + for k := range elm.Key { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + b.WriteRune('[') + b.WriteString(k) + b.WriteRune('=') + writeSafeString(b, elm.Key[k], ']') + b.WriteRune(']') + } + } + } + return b.String() +} + +// strPathV03 handles the v0.3 gnmi and earlier path.Element member. +func strPathV03(path *pb.Path) string { + return "/" + strings.Join(path.Element, "/") +} + +// upgradePath modernizes a Path by translating the contents of the Element field to Elem +func upgradePath(path *pb.Path) *pb.Path { + if len(path.Elem) == 0 { + var elems []*pb.PathElem + for _, element := range path.Element { + n, keys, _ := parseElement(element) + elems = append(elems, &pb.PathElem{Name: n, Key: keys}) + } + path.Elem = elems + path.Element = nil + } + return path +} + +// JoinPaths joins multiple gnmi paths and returns a string representation +func JoinPaths(paths ...*pb.Path) *pb.Path { + var elems []*pb.PathElem + for _, path := range paths { + path = upgradePath(path) + elems = append(elems, path.Elem...) + } + return &pb.Path{Elem: elems} +} + +func writeSafeString(b *strings.Builder, s string, esc rune) { + for _, c := range s { + if c == esc || c == '\\' { + b.WriteRune('\\') + } + b.WriteRune(c) + } +} + +// ParseGNMIElements builds up a gnmi path, from user-supplied text +func ParseGNMIElements(elms []string) (*pb.Path, error) { + var parsed []*pb.PathElem + for _, e := range elms { + n, keys, err := parseElement(e) + if err != nil { + return nil, err + } + parsed = append(parsed, &pb.PathElem{Name: n, Key: keys}) + } + return &pb.Path{ + Element: elms, // Backwards compatibility with pre-v0.4 gnmi + Elem: parsed, + }, nil +} + +// parseElement parses a path element, according to the gNMI specification. See +// https://github.com/openconfig/reference/blame/master/rpc/gnmi/gnmi-path-conventions.md +// +// It returns the first string (the current element name), and an optional map of key name +// value pairs. +func parseElement(pathElement string) (string, map[string]string, error) { + // First check if there are any keys, i.e. do we have at least one '[' in the element + name, keyStart := findUnescaped(pathElement, '[') + if keyStart < 0 { + return name, nil, nil + } + + // Error if there is no element name or if the "[" is at the beginning of the path element + if len(name) == 0 { + return "", nil, fmt.Errorf("failed to find element name in %q", pathElement) + } + + // Look at the keys now. + keys := make(map[string]string) + keyPart := pathElement[keyStart:] + for keyPart != "" { + k, v, nextKey, err := parseKey(keyPart) + if err != nil { + return "", nil, err + } + keys[k] = v + keyPart = nextKey + } + return name, keys, nil +} + +// parseKey returns the key name, key value and the remaining string to be parsed, +func parseKey(s string) (string, string, string, error) { + if s[0] != '[' { + return "", "", "", fmt.Errorf("failed to find opening '[' in %q", s) + } + k, iEq := findUnescaped(s[1:], '=') + if iEq < 0 { + return "", "", "", fmt.Errorf("failed to find '=' in %q", s) + } + if k == "" { + return "", "", "", fmt.Errorf("failed to find key name in %q", s) + } + + rhs := s[1+iEq+1:] + v, iClosBr := findUnescaped(rhs, ']') + if iClosBr < 0 { + return "", "", "", fmt.Errorf("failed to find ']' in %q", s) + } + if v == "" { + return "", "", "", fmt.Errorf("failed to find key value in %q", s) + } + + next := rhs[iClosBr+1:] + return k, v, next, nil +} + +// findUnescaped will return the index of the first unescaped match of 'find', and the unescaped +// string leading up to it. +func findUnescaped(s string, find byte) (string, int) { + // Take a fast track if there are no escape sequences + if strings.IndexByte(s, '\\') == -1 { + i := strings.IndexByte(s, find) + if i < 0 { + return s, -1 + } + return s[:i], i + } + + // Find the first match, taking care of escaped chars. + var b strings.Builder + var i int + len := len(s) + for i = 0; i < len; { + ch := s[i] + if ch == find { + return b.String(), i + } else if ch == '\\' && i < len-1 { + i++ + ch = s[i] + } + b.WriteByte(ch) + i++ + } + return b.String(), -1 +} diff --git a/forks/goarista/gnmi/path_test.go b/forks/goarista/gnmi/path_test.go new file mode 100644 index 0000000000000000000000000000000000000000..27318b65c10a64949326995b727347d3f5de211a --- /dev/null +++ b/forks/goarista/gnmi/path_test.go @@ -0,0 +1,308 @@ +// Copyright (c) 2017 Arista Networks, Inc. +// Use of this source code is governed by the Apache License 2.0 +// that can be found in the COPYING file. + +package gnmi + +import ( + "fmt" + "testing" + + "github.com/aristanetworks/goarista/test" + + pb "github.com/openconfig/gnmi/proto/gnmi" +) + +func p(s ...string) []string { + return s +} + +func TestSplitPath(t *testing.T) { + for i, tc := range []struct { + in string + exp []string + }{{ + in: "/foo/bar", + exp: p("foo", "bar"), + }, { + in: "/foo/bar/", + exp: p("foo", "bar"), + }, { + in: "//foo//bar//", + exp: p("", "foo", "", "bar", ""), + }, { + in: "/foo[name=///]/bar", + exp: p("foo[name=///]", "bar"), + }, { + in: `/foo[name=[\\\]/]/bar`, + exp: p(`foo[name=[\\\]/]`, "bar"), + }, { + in: `/foo[name=[\\]/bar`, + exp: p(`foo[name=[\\]`, "bar"), + }, { + in: "/foo[a=1][b=2]/bar", + exp: p("foo[a=1][b=2]", "bar"), + }, { + in: "/foo[a=1\\]2][b=2]/bar", + exp: p("foo[a=1\\]2][b=2]", "bar"), + }, { + in: "/foo[a=1][b=2]/bar\\baz", + exp: p("foo[a=1][b=2]", "bar\\baz"), + }} { + got := SplitPath(tc.in) + if !test.DeepEqual(tc.exp, got) { + t.Errorf("[%d] unexpect split for %q. Expected: %v, Got: %v", + i, tc.in, tc.exp, got) + } + } +} + +func TestStrPath(t *testing.T) { + for i, tc := range []struct { + path string + }{{ + path: "/", + }, { + path: "/foo/bar", + }, { + path: "/foo[name=a]/bar", + }, { + path: "/foo[a=1][b=2]/bar", + }, { + path: "/foo[a=1\\]2][b=2]/bar", + }, { + path: "/foo[a=1][b=2]/bar\\/baz", + }} { + sElms := SplitPath(tc.path) + pbPath, err := ParseGNMIElements(sElms) + if err != nil { + t.Errorf("failed to parse %s: %s", sElms, err) + } + s := StrPath(pbPath) + if !test.DeepEqual(tc.path, s) { + t.Errorf("[%d] want %s, got %s", i, tc.path, s) + } + } +} + +func TestStrPathBackwardsCompat(t *testing.T) { + for i, tc := range []struct { + path *pb.Path + str string + }{{ + path: &pb.Path{ + Element: p("foo[a=1][b=2]", "bar"), + }, + str: "/foo[a=1][b=2]/bar", + }} { + got := StrPath(tc.path) + if got != tc.str { + t.Errorf("[%d] want %q, got %q", i, tc.str, got) + } + } +} + +func TestParseElement(t *testing.T) { + // test cases + cases := []struct { + // name is the name of the test useful if you want to run a single test + // from the command line -run TestParseElement/<name> + name string + // in is the path element to be parsed + in string + // fieldName is field name (YANG node name) expected to be parsed from the path element. + // Normally this is simply the path element, or if the path element contains keys this is + // the text before the first [ + fieldName string + // keys is a map of the expected key value pairs from within the []s in the + // `path element. + // + // For example prefix[ip-prefix=10.0.0.0/24][masklength-range=26..28] + // fieldName would be "prefix" + // keys would be {"ip-prefix": "10.0.0.0/24", "masklength-range": "26..28"} + keys map[string]string + // expectedError is the exact error we expect. + expectedError error + }{{ + name: "no_elms", + in: "hello", + fieldName: "hello", + }, { + name: "single_open", + in: "[", + expectedError: fmt.Errorf("failed to find element name in %q", "["), + }, { + name: "no_equal_no_close", + in: "hello[there", + expectedError: fmt.Errorf("failed to find '=' in %q", "[there"), + }, { + name: "no_equals", + in: "hello[there]", + expectedError: fmt.Errorf("failed to find '=' in %q", "[there]"), + }, { + name: "no_left_side", + in: "hello[=there]", + expectedError: fmt.Errorf("failed to find key name in %q", "[=there]"), + }, { + name: "no_right_side", + in: "hello[there=]", + expectedError: fmt.Errorf("failed to find key value in %q", "[there=]"), + }, { + name: "hanging_escape", + in: "hello[there\\", + expectedError: fmt.Errorf("failed to find '=' in %q", "[there\\"), + }, { + name: "single_name_value", + in: "hello[there=where]", + fieldName: "hello", + keys: map[string]string{"there": "where"}, + }, { + name: "single_value_with=", + in: "hello[there=whe=r=e]", + fieldName: "hello", + keys: map[string]string{"there": "whe=r=e"}, + }, { + name: "single_value_with=_and_escaped_]", + in: `hello[there=whe=\]r=e]`, + fieldName: "hello", + keys: map[string]string{"there": `whe=]r=e`}, + }, { + name: "single_value_with[", + in: "hello[there=w[[here]", + fieldName: "hello", + keys: map[string]string{"there": "w[[here"}, + }, { + name: "value_single_open", + in: "hello[first=value][", + expectedError: fmt.Errorf("failed to find '=' in %q", "["), + }, { + name: "value_no_close", + in: "hello[there=where][somename", + expectedError: fmt.Errorf("failed to find '=' in %q", "[somename"), + }, { + name: "value_no_equals", + in: "hello[there=where][somename]", + expectedError: fmt.Errorf("failed to find '=' in %q", "[somename]"), + }, { + name: "no_left_side", + in: "hello[there=where][=somevalue]", + expectedError: fmt.Errorf("failed to find key name in %q", "[=somevalue]"), + }, { + name: "no_right_side", + in: "hello[there=where][somename=]", + expectedError: fmt.Errorf("failed to find key value in %q", "[somename=]"), + }, { + name: "two_name_values", + in: "hello[there=where][somename=somevalue]", + fieldName: "hello", + keys: map[string]string{"there": "where", "somename": "somevalue"}, + }, { + name: "three_name_values", + in: "hello[there=where][somename=somevalue][anothername=value]", + fieldName: "hello", + keys: map[string]string{"there": "where", "somename": "somevalue", + "anothername": "value"}, + }, { + name: "aserisk_value", + in: "hello[there=*][somename=somevalue][anothername=value]", + fieldName: "hello", + keys: map[string]string{"there": "*", "somename": "somevalue", + "anothername": "value"}, + }} + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fieldName, keys, err := parseElement(tc.in) + if !test.DeepEqual(tc.expectedError, err) { + t.Fatalf("[%s] expected err %#v, got %#v", tc.name, tc.expectedError, err) + } + if !test.DeepEqual(tc.keys, keys) { + t.Fatalf("[%s] expected output %#v, got %#v", tc.name, tc.keys, keys) + } + if tc.fieldName != fieldName { + t.Fatalf("[%s] expected field name %s, got %s", tc.name, tc.fieldName, fieldName) + } + }) + } +} + +func strToPath(pathStr string) *pb.Path { + splitPath := SplitPath(pathStr) + path, _ := ParseGNMIElements(splitPath) + path.Element = nil + return path +} + +func strsToPaths(pathStrs []string) []*pb.Path { + var paths []*pb.Path + for _, splitPath := range SplitPaths(pathStrs) { + path, _ := ParseGNMIElements(splitPath) + path.Element = nil + paths = append(paths, path) + } + return paths +} + +func TestJoinPath(t *testing.T) { + cases := []struct { + paths []*pb.Path + exp string + }{{ + paths: strsToPaths([]string{"/foo/bar", "/baz/qux"}), + exp: "/foo/bar/baz/qux", + }, + { + paths: strsToPaths([]string{ + "/foo/bar[somekey=someval][otherkey=otherval]", "/baz/qux"}), + exp: "/foo/bar[otherkey=otherval][somekey=someval]/baz/qux", + }, + { + paths: strsToPaths([]string{ + "/foo/bar[somekey=someval][otherkey=otherval]", + "/baz/qux[somekey=someval][otherkey=otherval]"}), + exp: "/foo/bar[otherkey=otherval][somekey=someval]/" + + "baz/qux[otherkey=otherval][somekey=someval]", + }, + { + paths: []*pb.Path{ + {Element: []string{"foo", "bar[somekey=someval][otherkey=otherval]"}}, + {Element: []string{"baz", "qux[somekey=someval][otherkey=otherval]"}}}, + exp: "/foo/bar[somekey=someval][otherkey=otherval]/" + + "baz/qux[somekey=someval][otherkey=otherval]", + }, + } + + for _, tc := range cases { + got := JoinPaths(tc.paths...) + exp := strToPath(tc.exp) + exp.Element = nil + if !test.DeepEqual(got, exp) { + t.Fatalf("ERROR!\n Got: %s,\n Want %s\n", got, exp) + } + } +} + +func BenchmarkPathElementToSigleElementName(b *testing.B) { + for i := 0; i < b.N; i++ { + _, _, _ = parseElement("hello") + } +} + +func BenchmarkPathElementTwoKeys(b *testing.B) { + for i := 0; i < b.N; i++ { + _, _, _ = parseElement("hello[hello=world][bye=moon]") + } +} + +func BenchmarkPathElementBadKeys(b *testing.B) { + for i := 0; i < b.N; i++ { + _, _, _ = parseElement("hello[hello=world][byemoon]") + } +} + +func BenchmarkPathElementMaxKeys(b *testing.B) { + for i := 0; i < b.N; i++ { + _, _, _ = parseElement("hello[name=firstName][name=secondName][name=thirdName]" + + "[name=fourthName][name=fifthName][name=sixthName]") + } +} diff --git a/forks/goarista/openconfig/client/client.go b/forks/goarista/openconfig/client/client.go new file mode 100644 index 0000000000000000000000000000000000000000..1d7d1992f375d06b747161dc4570124696202fac --- /dev/null +++ b/forks/goarista/openconfig/client/client.go @@ -0,0 +1,132 @@ +// Copyright (c) 2016 Arista Networks, Inc. +// Use of this source code is governed by the Apache License 2.0 +// that can be found in the COPYING file. + +// Package ciena provides helper functions for OpenConfig CLI tools. +package client + +import ( + "io" + "strings" + "sync" + + "github.com/golang/glog" + "github.com/golang/protobuf/proto" + "github.com/openconfig/reference/rpc/openconfig" + "golang.org/x/net/context" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" +) + +const defaultPort = "6030" + +// PublishFunc is the method to publish responses +type PublishFunc func(addr string, message proto.Message) + +// Client is a connected gRPC ciena +type Client struct { + client openconfig.OpenConfigClient + ctx context.Context + device string +} + +// New creates a new gRPC ciena and connects it +func New(username, password, addr string, opts []grpc.DialOption) *Client { + device := addr + if !strings.ContainsRune(addr, ':') { + addr += ":" + defaultPort + } + // Make sure we don't move past the grpc.Dial() call until we actually + // established an HTTP/2 connection successfully. + opts = append(opts, grpc.WithBlock()) + conn, err := grpc.Dial(addr, opts...) + if err != nil { + glog.Fatalf("Failed to dial: %s", err) + } + glog.Infof("Connected to %s", addr) + client := openconfig.NewOpenConfigClient(conn) + + ctx := context.Background() + if username != "" { + ctx = metadata.NewOutgoingContext(ctx, metadata.Pairs( + "username", username, + "password", password)) + } + return &Client{ + client: client, + device: device, + ctx: ctx, + } +} + +// Get sends a get request and returns the responses +func (c *Client) Get(path string) []*openconfig.Notification { + req := &openconfig.GetRequest{ + Path: []*openconfig.Path{ + { + Element: strings.Split(path, "/"), + }, + }, + } + response, err := c.client.Get(c.ctx, req) + if err != nil { + glog.Fatalf("Get failed: %s", err) + } + return response.Notification +} + +// Subscribe sends subscriptions, and consumes responses. +// The given publish function is used to publish SubscribeResponses received +// for the given subscriptions, when connected to the given host, with the +// given user/pass pair, or the ciena-side cert specified in the gRPC opts. +// This function does not normally return so it should probably be run in its +// own goroutine. When this function returns, the given WaitGroup is marked +// as done. +func (c *Client) Subscribe(wg *sync.WaitGroup, subscriptions []string, + publish PublishFunc) { + defer wg.Done() + stream, err := c.client.Subscribe(c.ctx) + if err != nil { + glog.Fatalf("Subscribe failed: %s", err) + } + defer stream.CloseSend() + + for _, path := range subscriptions { + sub := &openconfig.SubscribeRequest{ + Request: &openconfig.SubscribeRequest_Subscribe{ + Subscribe: &openconfig.SubscriptionList{ + Subscription: []*openconfig.Subscription{ + { + Path: &openconfig.Path{Element: strings.Split(path, "/")}, + }, + }, + }, + }, + } + + glog.Infof("Sending subscribe request: %s", sub) + err = stream.Send(sub) + if err != nil { + glog.Fatalf("Failed to subscribe: %s", err) + } + } + + for { + resp, err := stream.Recv() + if err != nil { + if err != io.EOF { + glog.Fatalf("Error received from the server: %s", err) + } + return + } + switch resp := resp.Response.(type) { + case *openconfig.SubscribeResponse_SyncResponse: + if !resp.SyncResponse { + panic("initial sync failed," + + " check that you're using a ciena compatible with the server") + } + } + glog.V(3).Info(resp) + publish(c.device, resp) + } +} diff --git a/forks/goarista/openconfig/client/flags.go b/forks/goarista/openconfig/client/flags.go new file mode 100644 index 0000000000000000000000000000000000000000..fb91bba3715c286babedb3e58caf221b576d213b --- /dev/null +++ b/forks/goarista/openconfig/client/flags.go @@ -0,0 +1,113 @@ +// Copyright (c) 2016 Arista Networks, Inc. +// Use of this source code is governed by the Apache License 2.0 +// that can be found in the COPYING file. + +package client + +import ( + "crypto/tls" + "crypto/x509" + "flag" + "io/ioutil" + "os" + "strings" + + "github.com/golang/glog" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" +) + +const ( + // HostnameArg is the value to be replaced by the actual hostname + HostnameArg = "HOSTNAME" +) + +// ParseHostnames parses a comma-separated list of names and replaces HOSTNAME with the current +// hostname in it +func ParseHostnames(list string) ([]string, error) { + items := strings.Split(list, ",") + hostname, err := os.Hostname() + if err != nil { + return nil, err + } + names := make([]string, len(items)) + for i, name := range items { + if name == HostnameArg { + name = hostname + } + names[i] = name + } + return names, nil +} + +// ParseFlags registers some additional common flags, +// parses the flags, and returns the resulting gRPC options, +// and other settings to connect to the gRPC interface. +func ParseFlags() (username string, password string, subscriptions, addrs []string, + opts []grpc.DialOption) { + + var ( + addrsFlag = flag.String("addrs", "localhost:6030", + "Comma-separated list of addresses of OpenConfig gRPC servers. The address 'HOSTNAME' "+ + "is replaced by the current hostname.") + + caFileFlag = flag.String("cafile", "", + "Path to server TLS certificate file") + + certFileFlag = flag.String("certfile", "", + "Path to ciena TLS certificate file") + + keyFileFlag = flag.String("keyfile", "", + "Path to ciena TLS private key file") + + passwordFlag = flag.String("password", "", + "Password to authenticate with") + + subscribeFlag = flag.String("subscribe", "", + "Comma-separated list of paths to subscribe to upon connecting to the server") + + usernameFlag = flag.String("username", "", + "Username to authenticate with") + + tlsFlag = flag.Bool("tls", false, + "Enable TLS") + ) + + flag.Parse() + if *tlsFlag || *caFileFlag != "" || *certFileFlag != "" { + config := &tls.Config{} + if *caFileFlag != "" { + b, err := ioutil.ReadFile(*caFileFlag) + if err != nil { + glog.Fatal(err) + } + cp := x509.NewCertPool() + if !cp.AppendCertsFromPEM(b) { + glog.Fatalf("credentials: failed to append certificates") + } + config.RootCAs = cp + } else { + config.InsecureSkipVerify = true + } + if *certFileFlag != "" { + if *keyFileFlag == "" { + glog.Fatalf("Please provide both -certfile and -keyfile") + } + cert, err := tls.LoadX509KeyPair(*certFileFlag, *keyFileFlag) + if err != nil { + glog.Fatal(err) + } + config.Certificates = []tls.Certificate{cert} + } + opts = append(opts, grpc.WithTransportCredentials(credentials.NewTLS(config))) + } else { + opts = append(opts, grpc.WithInsecure()) + } + var err error + addrs, err = ParseHostnames(*addrsFlag) + if err != nil { + glog.Fatal(err) + } + subscriptions = strings.Split(*subscribeFlag, ",") + return *usernameFlag, *passwordFlag, subscriptions, addrs, opts +} diff --git a/forks/goarista/openconfig/json.go b/forks/goarista/openconfig/json.go new file mode 100644 index 0000000000000000000000000000000000000000..8eba88baded76b426451ef862d332a3c712a1db2 --- /dev/null +++ b/forks/goarista/openconfig/json.go @@ -0,0 +1,237 @@ +// Copyright (c) 2016 Arista Networks, Inc. +// Use of this source code is governed by the Apache License 2.0 +// that can be found in the COPYING file. + +package openconfig + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + + "github.com/openconfig/reference/rpc/openconfig" +) + +// joinPath builds a string out of an Element +func joinPath(path *openconfig.Path) string { + if path == nil { + return "" + } + return strings.Join(path.Element, "/") +} + +func convertUpdate(update *openconfig.Update) (interface{}, error) { + switch update.Value.Type { + case openconfig.Type_JSON: + var value interface{} + decoder := json.NewDecoder(bytes.NewReader(update.Value.Value)) + decoder.UseNumber() + if err := decoder.Decode(&value); err != nil { + return nil, fmt.Errorf("Malformed JSON update %q in %s", + update.Value.Value, update) + } + return value, nil + case openconfig.Type_BYTES: + return update.Value.Value, nil + default: + return nil, + fmt.Errorf("Unhandled type of value %v in %s", update.Value.Type, update) + } +} + +// NotificationToJSON converts a Notification into a JSON string +func NotificationToJSON(notif *openconfig.Notification) (string, error) { + m := make(map[string]interface{}, 1) + m["timestamp"] = notif.Timestamp + m["path"] = "/" + joinPath(notif.Prefix) + if len(notif.Update) != 0 { + updates := make(map[string]interface{}, len(notif.Update)) + var err error + for _, update := range notif.Update { + updates[joinPath(update.Path)], err = convertUpdate(update) + if err != nil { + return "", err + } + } + m["updates"] = updates + } + if len(notif.Delete) != 0 { + deletes := make([]string, len(notif.Delete)) + for i, del := range notif.Delete { + deletes[i] = joinPath(del) + } + m["deletes"] = deletes + } + m = map[string]interface{}{"notification": m} + js, err := json.MarshalIndent(m, "", " ") + if err != nil { + return "", err + } + return string(js), nil +} + +// SubscribeResponseToJSON converts a SubscribeResponse into a JSON string +func SubscribeResponseToJSON(resp *openconfig.SubscribeResponse) (string, error) { + m := make(map[string]interface{}, 1) + var err error + switch resp := resp.Response.(type) { + case *openconfig.SubscribeResponse_Update: + return NotificationToJSON(resp.Update) + case *openconfig.SubscribeResponse_Heartbeat: + m["heartbeat"] = resp.Heartbeat.Interval + case *openconfig.SubscribeResponse_SyncResponse: + m["syncResponse"] = resp.SyncResponse + default: + return "", fmt.Errorf("Unknown type of response: %T: %s", resp, resp) + } + js, err := json.MarshalIndent(m, "", " ") + if err != nil { + return "", err + } + return string(js), nil +} + +// EscapeFunc is the escaping method for attribute names +type EscapeFunc func(k string) string + +// escapeValue looks for maps in an interface and escapes their keys +func escapeValue(value interface{}, escape EscapeFunc) interface{} { + valueMap, ok := value.(map[string]interface{}) + if !ok { + return value + } + escapedMap := make(map[string]interface{}, len(valueMap)) + for k, v := range valueMap { + escapedKey := escape(k) + escapedMap[escapedKey] = escapeValue(v, escape) + } + return escapedMap +} + +// addPathToMap creates a map[string]interface{} from a path. It returns the node in +// the map corresponding to the last element in the path +func addPathToMap(root map[string]interface{}, path []string, escape EscapeFunc) ( + map[string]interface{}, error) { + parent := root + for _, element := range path { + k := escape(element) + node, found := parent[k] + if !found { + node = map[string]interface{}{} + parent[k] = node + } + var ok bool + parent, ok = node.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf( + "Node %s is of type %T (expected map[string]interface traversing %q)", + element, node, path) + } + } + return parent, nil +} + +// NotificationToMap maps a Notification into a nested map of entities +func NotificationToMap(addr string, notification *openconfig.Notification, + escape EscapeFunc) (map[string]interface{}, error) { + if escape == nil { + escape = func(name string) string { + return name + } + } + prefix := notification.GetPrefix() + + // Convert deletes + var deletes map[string]interface{} + notificationDeletes := notification.GetDelete() + if notificationDeletes != nil { + deletes = make(map[string]interface{}) + node := deletes + if prefix != nil { + var err error + node, err = addPathToMap(node, prefix.Element, escape) + if err != nil { + return nil, err + } + } + for _, delete := range notificationDeletes { + _, err := addPathToMap(node, delete.Element, escape) + if err != nil { + return nil, err + } + } + } + + // Convert updates + var updates map[string]interface{} + notificationUpdates := notification.GetUpdate() + if notificationUpdates != nil { + updates = make(map[string]interface{}) + node := updates + if prefix != nil { + var err error + node, err = addPathToMap(node, prefix.Element, escape) + if err != nil { + return nil, err + } + } + for _, update := range notificationUpdates { + updateNode := node + path := update.GetPath() + elementLen := len(path.Element) + + // Convert all elements before the leaf + if elementLen > 1 { + parentElements := path.Element[:elementLen-1] + var err error + updateNode, err = addPathToMap(updateNode, parentElements, escape) + if err != nil { + return nil, err + } + } + + // Convert the value in the leaf + value := update.GetValue() + var unmarshaledValue interface{} + switch value.Type { + case openconfig.Type_JSON: + if err := json.Unmarshal(value.Value, &unmarshaledValue); err != nil { + return nil, err + } + case openconfig.Type_BYTES: + unmarshaledValue = update.Value.Value + default: + return nil, fmt.Errorf("Unexpected value type %s for path %v", + value.Type, path) + } + updateNode[escape(path.Element[elementLen-1])] = escapeValue( + unmarshaledValue, escape) + } + } + + // Build the complete map to return + root := map[string]interface{}{ + "timestamp": notification.Timestamp, + } + if addr != "" { + root["dataset"] = addr + } + if deletes != nil { + root["delete"] = deletes + } + if updates != nil { + root["update"] = updates + } + return root, nil +} + +// NotificationToJSONDocument maps a Notification into a single JSON document +func NotificationToJSONDocument(addr string, notification *openconfig.Notification, + escape EscapeFunc) ([]byte, error) { + m, err := NotificationToMap(addr, notification, escape) + if err != nil { + return nil, err + } + return json.Marshal(m) +} diff --git a/forks/goarista/openconfig/json_test.go b/forks/goarista/openconfig/json_test.go new file mode 100644 index 0000000000000000000000000000000000000000..1dfc41d5789b9c8fcbb1a6a8b30918f77873a88a --- /dev/null +++ b/forks/goarista/openconfig/json_test.go @@ -0,0 +1,143 @@ +// Copyright (c) 2016 Arista Networks, Inc. +// Use of this source code is governed by the Apache License 2.0 +// that can be found in the COPYING file. + +package openconfig + +import ( + "encoding/json" + "testing" + + "github.com/aristanetworks/goarista/test" + + "github.com/openconfig/reference/rpc/openconfig" +) + +func TestNotificationToMap(t *testing.T) { + value := map[string]interface{}{ + "239.255.255.250_0.0.0.0": map[string]interface{}{ + "creationTime": 4.567969230573434e+06, + }, + } + valueJSON, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + tests := []struct { + notification openconfig.Notification + json map[string]interface{} + }{{ + notification: openconfig.Notification{ + Prefix: &openconfig.Path{ + Element: []string{ + "foo", + }, + }, + Update: []*openconfig.Update{ + { + Path: &openconfig.Path{ + Element: []string{ + "route1", + }, + }, + Value: &openconfig.Value{ + Value: valueJSON, + }, + }, { + Path: &openconfig.Path{ + Element: []string{ + "route2", + }, + }, + Value: &openconfig.Value{ + Value: valueJSON, + }, + }}, + }, + json: map[string]interface{}{ + "timestamp": int64(0), + "dataset": "cairo", + "update": map[string]interface{}{ + "foo": map[string]interface{}{ + "route1": map[string]interface{}{ + "239.255.255.250_0.0.0.0": map[string]interface{}{ + "creationTime": 4.567969230573434e+06, + }, + }, + "route2": map[string]interface{}{ + "239.255.255.250_0.0.0.0": map[string]interface{}{ + "creationTime": 4.567969230573434e+06, + }, + }, + }, + }, + }, + }, { + notification: openconfig.Notification{ + Prefix: &openconfig.Path{ + Element: []string{ + "foo", "bar", + }, + }, + Delete: []*openconfig.Path{ + { + Element: []string{ + "route", "237.255.255.250_0.0.0.0", + }}, + { + Element: []string{ + "route", "238.255.255.250_0.0.0.0", + }, + }, + }, + Update: []*openconfig.Update{{ + Path: &openconfig.Path{ + Element: []string{ + "route", + }, + }, + Value: &openconfig.Value{ + Value: valueJSON, + }, + }}, + }, + json: map[string]interface{}{ + "timestamp": int64(0), + "dataset": "cairo", + "delete": map[string]interface{}{ + "foo": map[string]interface{}{ + "bar": map[string]interface{}{ + "route": map[string]interface{}{ + "237.255.255.250_0.0.0.0": map[string]interface{}{}, + "238.255.255.250_0.0.0.0": map[string]interface{}{}, + }, + }, + }, + }, + "update": map[string]interface{}{ + "foo": map[string]interface{}{ + "bar": map[string]interface{}{ + "route": map[string]interface{}{ + "239.255.255.250_0.0.0.0": map[string]interface{}{ + "creationTime": 4.567969230573434e+06, + }, + }, + }, + }, + }, + }, + }} + for _, tcase := range tests { + actual, err := NotificationToMap("cairo", &tcase.notification, nil) + if err != nil { + t.Fatal(err) + } + diff := test.Diff(tcase.json, actual) + if len(diff) > 0 { + expectedJSON, _ := json.Marshal(tcase.json) + actualJSON, _ := json.Marshal(actual) + t.Fatalf("Unexpected diff: %s\nExpected:\n%s\nGot:\n%s\n)", diff, expectedJSON, + actualJSON) + } + } +} diff --git a/go.mod b/go.mod index 704d65fb45730d13c36476f898fab07824e4e357..cec3a1d5e20c459677d3225f5bda04b71e285efa 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.14 require ( code.fbi.h-da.de/cocsn/swagger/apis v0.0.0-20200924152423-61030cab7b88 code.fbi.h-da.de/cocsn/yang-models v0.0.3 - github.com/aristanetworks/goarista v0.0.0-20201120222254-94a892eb0c6a // indirect + github.com/aristanetworks/goarista v0.0.0-20201120222254-94a892eb0c6a github.com/gdamore/tcell/v2 v2.0.1-0.20201017141208-acf90d56d591 github.com/go-openapi/runtime v0.19.22 github.com/go-openapi/strfmt v0.19.5 diff --git a/nucleus/cli-handling.go b/nucleus/cli-handling.go index ec668cae154b1468a509cdc69ff010c50775fbd5..6ebe88cec126b0cb6255fb00e3363f8648ae7a7a 100644 --- a/nucleus/cli-handling.go +++ b/nucleus/cli-handling.go @@ -9,6 +9,7 @@ package nucleus import ( "context" "github.com/google/uuid" + "github.com/spf13/viper" "io" "net" "os" @@ -110,7 +111,7 @@ func getCLIGoing(core *Core) { log.Info("Starting: GetCLIGoing") // Boot-up the control interface for the cli - cliControlListener, err := net.Listen("tcp", core.config.CliSocket) + cliControlListener, err := net.Listen("tcp", viper.GetString("socket")) if err != nil { log.Fatal(err) } diff --git a/nucleus/controller.go b/nucleus/controller.go index e30b0f0617ccd6e77289ee4a5631c33bf7424c52..9de78e91939e778db1c00f1c58b40d7d9ba9c72d 100644 --- a/nucleus/controller.go +++ b/nucleus/controller.go @@ -9,21 +9,11 @@ import ( "os" ) -type controllerConfig struct { - CliSocket string - DatabaseSocket string - DatabaseUser string - DatabasePassword string - DatabaseCrypto bool - ConfigPath string -} - // Core is the representation of the controllers core type Core struct { southboundInterfaces map[string]SouthboundInterface prinipalNetworkDomains map[uuid.UUID]PrincipalNetworkDomain database database.Database - config controllerConfig IsRunning chan bool } diff --git a/nucleus/device.go b/nucleus/device.go index 587678d3dc0a67407e55413dbe9ede57f10aeda7..f39d1bc6eb3d7582f352de1d8ed49423b61f561e 100644 --- a/nucleus/device.go +++ b/nucleus/device.go @@ -1,11 +1,11 @@ package nucleus import ( - opb "code.fbi.h-da.de/cocsn/gosdn/api/proto/openconfig/openconfig_interfaces" "code.fbi.h-da.de/cocsn/yang-models/generated/openconfig" "github.com/google/uuid" "github.com/openconfig/gnmi/proto/gnmi" "github.com/openconfig/ygot/ygot" + log "github.com/sirupsen/logrus" ) type Device struct { @@ -20,26 +20,22 @@ type Device struct { // Also all that Interface Call specific logic belongs to SBI! func (d Device) Add(resp interface{}) { r := resp.(*gnmi.GetResponse) + log.Debug(r) device := d.Device.(*openconfig.Device) + ifaces := make(map[string]*openconfig.OpenconfigInterfaces_Interfaces_Interface) + ifaces["test"] = &openconfig.OpenconfigInterfaces_Interfaces_Interface{ + Aggregation: nil, + Config: nil, + Ethernet: nil, + HoldTime: nil, + Name: nil, + RoutedVlan: nil, + State: nil, + Subinterfaces: nil, + } // It's possible that the gnmi response already is too Arista-fied to be used w/YGOT - ifs := opb.Interfaces{ - Interface: []*opb.Interfaces_InterfaceKey{ - { - Interface: &opb.Interfaces_Interface{ - Config: &opb.Interfaces_Interface_Config{ - Description: nil, - Enabled: nil, - LoopbackMode: nil, - Mtu: nil, - Name: nil, - Type: 0, - }, - HoldTime: nil, - State: nil, - Subinterfaces: nil, - }, - }, - }, + ifs := &openconfig.OpenconfigInterfaces_Interfaces{ + Interface: ifaces, } device.Interfaces = ifs d.Device = device