diff --git a/common_utils/context.go b/common_utils/context.go index a3ba4543..fdb3011d 100644 --- a/common_utils/context.go +++ b/common_utils/context.go @@ -42,6 +42,7 @@ const ( GNMI_GET_FAIL GNMI_SET GNMI_SET_FAIL + GNMI_SET_BYPASS GNOI_REBOOT GNOI_FACTORY_RESET GNOI_OS_INSTALL @@ -80,6 +81,8 @@ func (c CounterType) String() string { return "GNMI set" case GNMI_SET_FAIL: return "GNMI set fail" + case GNMI_SET_BYPASS: + return "GNMI set bypass" case GNOI_REBOOT: return "GNOI reboot" case GNOI_FACTORY_RESET: diff --git a/gnmi_server/server.go b/gnmi_server/server.go index 9237d279..5841dfc5 100644 --- a/gnmi_server/server.go +++ b/gnmi_server/server.go @@ -12,6 +12,7 @@ import ( "github.com/Azure/sonic-mgmt-common/translib" "github.com/sonic-net/sonic-gnmi/common_utils" + "github.com/sonic-net/sonic-gnmi/pkg/bypass" operationalhandler "github.com/sonic-net/sonic-gnmi/pkg/server/operational-handler" spb "github.com/sonic-net/sonic-gnmi/proto" spb_gnoi "github.com/sonic-net/sonic-gnmi/proto/gnoi" @@ -798,6 +799,18 @@ func (s *Server) Set(ctx context.Context, req *gnmipb.SetRequest) (*gnmipb.SetRe common_utils.IncCounter(common_utils.GNMI_SET_FAIL) return nil, grpc.Errorf(codes.Unimplemented, "GNMI native write is disabled") } + + // Fast path: bypass validation for allowed tables/SKUs + allUpdates := append(req.GetReplace(), req.GetUpdate()...) + if resp, used, err := bypass.TrySet(ctx, prefix, req.GetDelete(), allUpdates); used { + if err != nil { + common_utils.IncCounter(common_utils.GNMI_SET_FAIL) + return nil, status.Error(codes.Internal, err.Error()) + } + common_utils.IncCounter(common_utils.GNMI_SET_BYPASS) + return resp, nil + } + var targetDbName string dc, err = sdc.NewMixedDbClient(paths, prefix, origin, encoding, s.config.ZmqPort, s.config.Vrf, &targetDbName) authTarget = "gnmi_" + targetDbName diff --git a/pkg/bypass/bypass.go b/pkg/bypass/bypass.go new file mode 100644 index 00000000..b4747537 --- /dev/null +++ b/pkg/bypass/bypass.go @@ -0,0 +1,438 @@ +// Package bypass provides fast-path direct ConfigDB writes for gNMI Set operations, +// bypassing DBUS/GCU validation when specific conditions are met. +package bypass + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/go-redis/redis/v7" + "github.com/golang/glog" + gnmipb "github.com/openconfig/gnmi/proto/gnmi" + "google.golang.org/grpc/metadata" +) + +const ( + // MetadataKeyBypassValidation is the gRPC metadata key for bypass validation + MetadataKeyBypassValidation = "x-sonic-ss-bypass-validation" +) + +// AllowedTables lists ConfigDB tables that can bypass validation (exact match) +var AllowedTables = map[string]bool{ + "VNET": true, + "VNET_ROUTE_TUNNEL": true, + "VLAN_SUB_INTERFACE": true, + "ACL_RULE": true, + "BGP_PEER_RANGE": true, + "BGP_NEIGHBOR": true, + "PREFIX_LIST": true, +} + +// AllowedSKUPrefixes lists HwSku prefixes that can use bypass validation +var AllowedSKUPrefixes = []string{ + "Cisco-8102", + "Cisco-8101", + "Cisco-8223", +} + +// Default CONFIG_DB connection settings +// CONFIG_DB is database ID 4 in SONiC +const ( + defaultRedisSocket = "/var/run/redis/redis.sock" + defaultRedisTCP = "127.0.0.1:6379" + configDbId = 4 +) + +// getConfigDbClientFunc allows mocking in tests +var getConfigDbClientFunc = getConfigDbClientDefault + +func getConfigDbClientDefault() (*redis.Client, error) { + // Priority: REDIS_ADDR env > Unix socket > TCP fallback + addr := os.Getenv("REDIS_ADDR") + network := "unix" + + if addr != "" { + // Environment variable set - determine network type + if strings.HasPrefix(addr, "/") { + network = "unix" + } else { + network = "tcp" + } + } else { + // Try Unix socket first (better performance), fallback to TCP + if _, err := os.Stat(defaultRedisSocket); err == nil { + addr = defaultRedisSocket + network = "unix" + } else { + addr = defaultRedisTCP + network = "tcp" + } + } + + client := redis.NewClient(&redis.Options{ + Network: network, + Addr: addr, + Password: "", + DB: configDbId, + DialTimeout: 0, + }) + return client, nil +} + +// ShouldBypass checks if the request should use the fast bypass path. +// Returns true only if ALL conditions are met: +// 1. Metadata header x-sonic-ss-bypass-validation: true +// 2. SKU matches allowed prefixes +// 3. All target tables are in the allowlist +func ShouldBypass(ctx context.Context, prefix *gnmipb.Path, updates []*gnmipb.Update) bool { + if !hasBypassHeader(ctx) { + return false + } + if !checkSKU() { + return false + } + if !checkAllowedTables(prefix, updates) { + return false + } + return true +} + +// ShouldBypassDelete checks if delete paths should use the fast bypass path. +// Same conditions as ShouldBypass but for delete operations. +func ShouldBypassDelete(ctx context.Context, prefix *gnmipb.Path, deletes []*gnmipb.Path) bool { + if !hasBypassHeader(ctx) { + return false + } + if !checkSKU() { + return false + } + if !checkAllowedDeletePaths(prefix, deletes) { + return false + } + return true +} + +// checkAllowedDeletePaths verifies all delete paths target allowed tables +func checkAllowedDeletePaths(prefix *gnmipb.Path, deletes []*gnmipb.Path) bool { + for _, path := range deletes { + table := extractTable(prefix, path) + if table == "" { + glog.V(2).Infof("Bypass: could not extract table from delete path") + return false + } + if !AllowedTables[table] { + glog.V(2).Infof("Bypass: table %s not in allowlist for delete", table) + return false + } + } + return true +} + +// hasBypassHeader checks gRPC metadata for bypass header +func hasBypassHeader(ctx context.Context) bool { + if ctx == nil { + return false + } + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return false + } + if values := md.Get(MetadataKeyBypassValidation); len(values) > 0 { + return values[0] == "true" + } + return false +} + +// checkSKU verifies device SKU matches one of the allowed prefixes +func checkSKU() bool { + rclient, err := getConfigDbClientFunc() + if err != nil { + glog.V(2).Infof("Bypass: failed to get CONFIG_DB client: %v", err) + return false + } + defer rclient.Close() + + hwsku, err := rclient.HGet("DEVICE_METADATA|localhost", "hwsku").Result() + if err != nil { + glog.V(2).Infof("Bypass: failed to read SKU: %v", err) + return false + } + + for _, prefix := range AllowedSKUPrefixes { + if strings.HasPrefix(hwsku, prefix) { + return true + } + } + glog.V(2).Infof("Bypass: SKU %s does not match any allowed prefix", hwsku) + return false +} + +// checkAllowedTables verifies all target tables are in the allowlist +func checkAllowedTables(prefix *gnmipb.Path, updates []*gnmipb.Update) bool { + for _, update := range updates { + table := extractTable(prefix, update.GetPath()) + if table == "" { + glog.V(2).Infof("Bypass: could not extract table from path") + return false + } + if !AllowedTables[table] { + glog.V(2).Infof("Bypass: table %s not in allowlist", table) + return false + } + } + return true +} + +// extractTable extracts the table name from gNMI path +// Expected path format: CONFIG_DB/localhost/TABLE/KEY or just TABLE/KEY +func extractTable(prefix *gnmipb.Path, path *gnmipb.Path) string { + var elems []*gnmipb.PathElem + if prefix != nil { + elems = append(elems, prefix.GetElem()...) + } + if path != nil { + elems = append(elems, path.GetElem()...) + } + + // Find the table name - it comes after CONFIG_DB/localhost or is the first element + for i, elem := range elems { + name := elem.GetName() + if name == "CONFIG_DB" || name == "localhost" { + continue + } + // First non-CONFIG_DB/localhost element is the table + if i < len(elems) { + return name + } + } + return "" +} + +// Apply executes the bypass write directly to ConfigDB +// Returns nil on success, error on failure +func Apply(ctx context.Context, prefix *gnmipb.Path, updates []*gnmipb.Update) error { + rclient, err := getConfigDbClientFunc() + if err != nil { + return fmt.Errorf("bypass: failed to get CONFIG_DB client: %v", err) + } + defer rclient.Close() + + for _, update := range updates { + table, key, field := parsePath(prefix, update.GetPath()) + if table == "" { + return fmt.Errorf("bypass: invalid path, cannot extract table") + } + + val := update.GetVal() + jsonVal := val.GetJsonIetfVal() + + // Bulk table update: key is empty, JSON contains multiple entries + // Path: CONFIG_DB/localhost/TABLE + // JSON: {"entryKey1": {"field": "value"}, "entryKey2": {...}} + if key == "" { + if len(jsonVal) == 0 { + return fmt.Errorf("bypass: bulk update requires JSON value") + } + var bulkData map[string]map[string]interface{} + if err := json.Unmarshal(jsonVal, &bulkData); err != nil { + return fmt.Errorf("bypass: failed to unmarshal bulk JSON: %v", err) + } + for entryKey, entryFields := range bulkData { + redisKey := table + "|" + entryKey + fields := convertToRedisFields(entryFields) + // For empty entry, use NULL placeholder (SONiC convention) + if len(fields) == 0 { + fields["NULL"] = "NULL" + } + if _, err := rclient.HMSet(redisKey, fields).Result(); err != nil { + return fmt.Errorf("bypass: HMSet failed for %s: %v", redisKey, err) + } + glog.V(2).Infof("Bypass: wrote %s with %d fields", redisKey, len(fields)) + } + continue + } + + // Single entry update: path has TABLE/KEY + redisKey := table + "|" + key + + // Handle JSON IETF value + if len(jsonVal) > 0 { + var data map[string]interface{} + if err := json.Unmarshal(jsonVal, &data); err != nil { + return fmt.Errorf("bypass: failed to unmarshal JSON: %v", err) + } + + fields := convertToRedisFields(data) + // For empty JSON {}, use NULL placeholder (SONiC convention for empty entries) + if len(fields) == 0 { + fields["NULL"] = "NULL" + } + if _, err := rclient.HMSet(redisKey, fields).Result(); err != nil { + return fmt.Errorf("bypass: HMSet failed for %s: %v", redisKey, err) + } + glog.V(2).Infof("Bypass: wrote %s with %d fields", redisKey, len(fields)) + continue + } + + // Handle scalar value for single field update + if field != "" { + strVal := "" + if v := val.GetStringVal(); v != "" { + strVal = v + } else if v := val.GetIntVal(); v != 0 { + strVal = fmt.Sprintf("%d", v) + } else if v := val.GetUintVal(); v != 0 { + strVal = fmt.Sprintf("%d", v) + } + if _, err := rclient.HSet(redisKey, field, strVal).Result(); err != nil { + return fmt.Errorf("bypass: HSet failed for %s.%s: %v", redisKey, field, err) + } + glog.V(2).Infof("Bypass: wrote %s.%s = %s", redisKey, field, strVal) + } + } + + return nil +} + +// Delete executes bypass delete directly to ConfigDB +// Returns nil on success, error on failure +func Delete(ctx context.Context, prefix *gnmipb.Path, deletes []*gnmipb.Path) error { + rclient, err := getConfigDbClientFunc() + if err != nil { + return fmt.Errorf("bypass: failed to get CONFIG_DB client: %v", err) + } + defer rclient.Close() + + for _, path := range deletes { + table, key, _ := parsePath(prefix, path) + if table == "" || key == "" { + return fmt.Errorf("bypass: invalid delete path, cannot extract table/key") + } + + redisKey := table + "|" + key + if _, err := rclient.Del(redisKey).Result(); err != nil { + return fmt.Errorf("bypass: Del failed for %s: %v", redisKey, err) + } + glog.V(2).Infof("Bypass: deleted %s", redisKey) + } + + return nil +} + +// parsePath extracts table, key, and optional field from gNMI path +func parsePath(prefix *gnmipb.Path, path *gnmipb.Path) (table, key, field string) { + var elems []*gnmipb.PathElem + if prefix != nil { + elems = append(elems, prefix.GetElem()...) + } + if path != nil { + elems = append(elems, path.GetElem()...) + } + + // Skip CONFIG_DB and localhost + var parts []string + for _, elem := range elems { + name := elem.GetName() + if name == "CONFIG_DB" || name == "localhost" { + continue + } + parts = append(parts, name) + } + + if len(parts) >= 1 { + table = parts[0] + } + if len(parts) >= 2 { + key = decodeJsonPointer(parts[1]) + } + if len(parts) >= 3 { + field = parts[2] + } + return +} + +// decodeJsonPointer decodes JSON Pointer escaping (RFC 6901) +// ~1 -> / +// ~0 -> ~ +func decodeJsonPointer(s string) string { + s = strings.ReplaceAll(s, "~1", "/") + s = strings.ReplaceAll(s, "~0", "~") + return s +} + +// convertToRedisFields converts a map of JSON values to SONiC Redis field format. +// SONiC convention: list/array fields use @ suffix and comma-separated values. +// Example: {"ip_range": ["10.0.1.0/24", "10.0.2.0/24"]} -> {"ip_range@": "10.0.1.0/24,10.0.2.0/24"} +func convertToRedisFields(data map[string]interface{}) map[string]interface{} { + fields := make(map[string]interface{}) + for k, v := range data { + switch val := v.(type) { + case []interface{}: + // SONiC convention: list fields use @ suffix and comma-separated values + var strVals []string + for _, item := range val { + strVals = append(strVals, fmt.Sprintf("%v", item)) + } + fields[k+"@"] = strings.Join(strVals, ",") + default: + fields[k] = fmt.Sprintf("%v", v) + } + } + return fields +} + +// TrySet attempts to execute a gNMI Set via the bypass fast path. +// Returns (response, true, nil) if bypass was used successfully. +// Returns (nil, true, error) if bypass was attempted but failed. +// Returns (nil, false, nil) if bypass conditions were not met (caller should use normal path). +func TrySet(ctx context.Context, prefix *gnmipb.Path, deletes []*gnmipb.Path, updates []*gnmipb.Update) (*gnmipb.SetResponse, bool, error) { + // Must have at least one operation + if len(updates) == 0 && len(deletes) == 0 { + return nil, false, nil + } + + // Check bypass conditions for all operations + if len(updates) > 0 && !ShouldBypass(ctx, prefix, updates) { + return nil, false, nil + } + if len(deletes) > 0 && !ShouldBypassDelete(ctx, prefix, deletes) { + return nil, false, nil + } + + glog.V(2).Infof("Bypass fast path: direct ConfigDB operations") + var results []*gnmipb.UpdateResult + + // Execute deletes first (per gNMI spec order) + if len(deletes) > 0 { + if err := Delete(ctx, prefix, deletes); err != nil { + return nil, true, err + } + for _, d := range deletes { + results = append(results, &gnmipb.UpdateResult{ + Path: d, + Op: gnmipb.UpdateResult_DELETE, + }) + } + } + + // Then execute updates + if len(updates) > 0 { + if err := Apply(ctx, prefix, updates); err != nil { + return nil, true, err + } + for _, u := range updates { + results = append(results, &gnmipb.UpdateResult{ + Path: u.GetPath(), + Op: gnmipb.UpdateResult_UPDATE, + }) + } + } + + return &gnmipb.SetResponse{ + Prefix: prefix, + Response: results, + }, true, nil +} diff --git a/pkg/bypass/bypass_test.go b/pkg/bypass/bypass_test.go new file mode 100644 index 00000000..820e82f0 --- /dev/null +++ b/pkg/bypass/bypass_test.go @@ -0,0 +1,1779 @@ +//go:build !gnmi_memcheck +// +build !gnmi_memcheck + +package bypass + +import ( + "context" + "fmt" + "os" + "testing" + + "github.com/alicebob/miniredis/v2" + "github.com/go-redis/redis/v7" + gnmipb "github.com/openconfig/gnmi/proto/gnmi" + "google.golang.org/grpc/metadata" +) + +func TestHasBypassHeader(t *testing.T) { + tests := []struct { + name string + ctx context.Context + expected bool + }{ + { + name: "nil context", + ctx: nil, + expected: false, + }, + { + name: "context without metadata", + ctx: context.Background(), + expected: false, + }, + { + name: "context with bypass=true", + ctx: metadata.NewIncomingContext( + context.Background(), + metadata.Pairs(MetadataKeyBypassValidation, "true"), + ), + expected: true, + }, + { + name: "context with bypass=false", + ctx: metadata.NewIncomingContext( + context.Background(), + metadata.Pairs(MetadataKeyBypassValidation, "false"), + ), + expected: false, + }, + { + name: "context with bypass=TRUE (case sensitive)", + ctx: metadata.NewIncomingContext( + context.Background(), + metadata.Pairs(MetadataKeyBypassValidation, "TRUE"), + ), + expected: false, + }, + { + name: "context with other metadata but no bypass", + ctx: metadata.NewIncomingContext( + context.Background(), + metadata.Pairs("other-key", "other-value"), + ), + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := hasBypassHeader(tt.ctx) + if result != tt.expected { + t.Errorf("hasBypassHeader() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestCheckSKU(t *testing.T) { + mr := miniredis.RunT(t) + defer mr.Close() + + originalFunc := getConfigDbClientFunc + defer func() { getConfigDbClientFunc = originalFunc }() + + getConfigDbClientFunc = func() (*redis.Client, error) { + return redis.NewClient(&redis.Options{ + Addr: mr.Addr(), + DB: 0, + }), nil + } + + tests := []struct { + name string + hwsku string + expected bool + }{ + { + name: "Cisco 8102 SKU", + hwsku: "Cisco-8102-28FH-DPU-C28", + expected: true, + }, + { + name: "Cisco 8101 SKU", + hwsku: "Cisco-8101-O32", + expected: true, + }, + { + name: "Cisco 8223 SKU", + hwsku: "Cisco-8223-something", + expected: true, + }, + { + name: "Non-matching SKU", + hwsku: "Force10-Z9100-C32", + expected: false, + }, + { + name: "Partial match not at prefix", + hwsku: "Something-Cisco-8102", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mr.FlushAll() + mr.HSet("DEVICE_METADATA|localhost", "hwsku", tt.hwsku) + + result := checkSKU() + if result != tt.expected { + t.Errorf("checkSKU() = %v, want %v for SKU %q", result, tt.expected, tt.hwsku) + } + }) + } +} + +func TestCheckSKU_NoMetadata(t *testing.T) { + mr := miniredis.RunT(t) + defer mr.Close() + + originalFunc := getConfigDbClientFunc + defer func() { getConfigDbClientFunc = originalFunc }() + + getConfigDbClientFunc = func() (*redis.Client, error) { + return redis.NewClient(&redis.Options{ + Addr: mr.Addr(), + DB: 0, + }), nil + } + + // Don't set any data - should return false + result := checkSKU() + if result != false { + t.Errorf("checkSKU() = %v, want false when DEVICE_METADATA is missing", result) + } +} + +func TestCheckAllowedTables(t *testing.T) { + tests := []struct { + name string + tables []string + expected bool + }{ + { + name: "single allowed table VNET", + tables: []string{"VNET"}, + expected: true, + }, + { + name: "single allowed table ACL_RULE", + tables: []string{"ACL_RULE"}, + expected: true, + }, + { + name: "multiple allowed tables", + tables: []string{"VNET", "VNET_ROUTE_TUNNEL", "BGP_PEER_RANGE"}, + expected: true, + }, + { + name: "disallowed table", + tables: []string{"PORT"}, + expected: false, + }, + { + name: "mixed allowed and disallowed", + tables: []string{"VNET", "PORT"}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var updates []*gnmipb.Update + for _, table := range tt.tables { + updates = append(updates, &gnmipb.Update{ + Path: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "CONFIG_DB"}, + {Name: "localhost"}, + {Name: table}, + {Name: "key1"}, + }, + }, + }) + } + + result := checkAllowedTables(nil, updates) + if result != tt.expected { + t.Errorf("checkAllowedTables() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestCheckAllowedDeletePaths(t *testing.T) { + tests := []struct { + name string + tables []string + expected bool + }{ + { + name: "single allowed table", + tables: []string{"VNET"}, + expected: true, + }, + { + name: "disallowed table", + tables: []string{"INTERFACE"}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var deletes []*gnmipb.Path + for _, table := range tt.tables { + deletes = append(deletes, &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "CONFIG_DB"}, + {Name: "localhost"}, + {Name: table}, + {Name: "key1"}, + }, + }) + } + + result := checkAllowedDeletePaths(nil, deletes) + if result != tt.expected { + t.Errorf("checkAllowedDeletePaths() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestExtractTable(t *testing.T) { + tests := []struct { + name string + path *gnmipb.Path + expected string + }{ + { + name: "full path with CONFIG_DB/localhost", + path: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "CONFIG_DB"}, + {Name: "localhost"}, + {Name: "VNET"}, + {Name: "vnet1"}, + }, + }, + expected: "VNET", + }, + { + name: "path without CONFIG_DB prefix", + path: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "VNET"}, + {Name: "vnet1"}, + }, + }, + expected: "VNET", + }, + { + name: "table only path", + path: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "CONFIG_DB"}, + {Name: "localhost"}, + {Name: "VNET_ROUTE_TUNNEL"}, + }, + }, + expected: "VNET_ROUTE_TUNNEL", + }, + { + name: "empty path", + path: &gnmipb.Path{}, + expected: "", + }, + { + name: "nil path", + path: nil, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractTable(nil, tt.path) + if result != tt.expected { + t.Errorf("extractTable() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestParsePath(t *testing.T) { + tests := []struct { + name string + path *gnmipb.Path + expectedTable string + expectedKey string + expectedField string + }{ + { + name: "TABLE/KEY path", + path: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "CONFIG_DB"}, + {Name: "localhost"}, + {Name: "VNET"}, + {Name: "vnet1"}, + }, + }, + expectedTable: "VNET", + expectedKey: "vnet1", + expectedField: "", + }, + { + name: "TABLE/KEY/FIELD path", + path: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "CONFIG_DB"}, + {Name: "localhost"}, + {Name: "VNET"}, + {Name: "vnet1"}, + {Name: "vni"}, + }, + }, + expectedTable: "VNET", + expectedKey: "vnet1", + expectedField: "vni", + }, + { + name: "TABLE only path (bulk)", + path: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "CONFIG_DB"}, + {Name: "localhost"}, + {Name: "VNET_ROUTE_TUNNEL"}, + }, + }, + expectedTable: "VNET_ROUTE_TUNNEL", + expectedKey: "", + expectedField: "", + }, + { + name: "key with JSON pointer encoding", + path: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "CONFIG_DB"}, + {Name: "localhost"}, + {Name: "VNET_ROUTE_TUNNEL"}, + {Name: "Vnet1|10.0.0.1~132"}, + }, + }, + expectedTable: "VNET_ROUTE_TUNNEL", + expectedKey: "Vnet1|10.0.0.1/32", + expectedField: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + table, key, field := parsePath(nil, tt.path) + if table != tt.expectedTable { + t.Errorf("parsePath() table = %v, want %v", table, tt.expectedTable) + } + if key != tt.expectedKey { + t.Errorf("parsePath() key = %v, want %v", key, tt.expectedKey) + } + if field != tt.expectedField { + t.Errorf("parsePath() field = %v, want %v", field, tt.expectedField) + } + }) + } +} + +func TestDecodeJsonPointer(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "decode ~1 to /", + input: "10.0.0.1~132", + expected: "10.0.0.1/32", + }, + { + name: "decode ~0 to ~", + input: "test~0value", + expected: "test~value", + }, + { + name: "no encoding", + input: "simple-key", + expected: "simple-key", + }, + { + name: "multiple ~1", + input: "a~1b~1c", + expected: "a/b/c", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := decodeJsonPointer(tt.input) + if result != tt.expected { + t.Errorf("decodeJsonPointer(%q) = %v, want %v", tt.input, result, tt.expected) + } + }) + } +} + +func TestApply(t *testing.T) { + mr := miniredis.RunT(t) + defer mr.Close() + + originalFunc := getConfigDbClientFunc + defer func() { getConfigDbClientFunc = originalFunc }() + + getConfigDbClientFunc = func() (*redis.Client, error) { + return redis.NewClient(&redis.Options{ + Addr: mr.Addr(), + DB: 0, + }), nil + } + + t.Run("single entry with JSON value", func(t *testing.T) { + mr.FlushAll() + updates := []*gnmipb.Update{ + { + Path: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "CONFIG_DB"}, + {Name: "localhost"}, + {Name: "VNET"}, + {Name: "vnet1"}, + }, + }, + Val: &gnmipb.TypedValue{ + Value: &gnmipb.TypedValue_JsonIetfVal{ + JsonIetfVal: []byte(`{"vni": "1000", "scope": "default"}`), + }, + }, + }, + } + + err := Apply(context.Background(), nil, updates) + if err != nil { + t.Errorf("Apply() error = %v", err) + } + + vni := mr.HGet("VNET|vnet1", "vni") + if vni != "1000" { + t.Errorf("Expected vni=1000, got %s", vni) + } + scope := mr.HGet("VNET|vnet1", "scope") + if scope != "default" { + t.Errorf("Expected scope=default, got %s", scope) + } + }) + + t.Run("bulk table update", func(t *testing.T) { + mr.FlushAll() + updates := []*gnmipb.Update{ + { + Path: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "CONFIG_DB"}, + {Name: "localhost"}, + {Name: "VNET_ROUTE_TUNNEL"}, + }, + }, + Val: &gnmipb.TypedValue{ + Value: &gnmipb.TypedValue_JsonIetfVal{ + JsonIetfVal: []byte(`{"Vnet1|10.0.0.1/32": {"endpoint": "1.1.1.1", "vni": "1000"}, "Vnet2|10.0.0.2/32": {"endpoint": "2.2.2.2", "vni": "2000"}}`), + }, + }, + }, + } + + err := Apply(context.Background(), nil, updates) + if err != nil { + t.Errorf("Apply() error = %v", err) + } + + endpoint1 := mr.HGet("VNET_ROUTE_TUNNEL|Vnet1|10.0.0.1/32", "endpoint") + if endpoint1 != "1.1.1.1" { + t.Errorf("Expected endpoint=1.1.1.1, got %s", endpoint1) + } + endpoint2 := mr.HGet("VNET_ROUTE_TUNNEL|Vnet2|10.0.0.2/32", "endpoint") + if endpoint2 != "2.2.2.2" { + t.Errorf("Expected endpoint=2.2.2.2, got %s", endpoint2) + } + }) + + t.Run("invalid path - no table", func(t *testing.T) { + updates := []*gnmipb.Update{ + { + Path: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "CONFIG_DB"}, + {Name: "localhost"}, + }, + }, + Val: &gnmipb.TypedValue{ + Value: &gnmipb.TypedValue_JsonIetfVal{ + JsonIetfVal: []byte(`{"key": "value"}`), + }, + }, + }, + } + + err := Apply(context.Background(), nil, updates) + if err == nil { + t.Error("Expected error for invalid path") + } + }) + + t.Run("bulk update without JSON", func(t *testing.T) { + updates := []*gnmipb.Update{ + { + Path: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "CONFIG_DB"}, + {Name: "localhost"}, + {Name: "VNET"}, + }, + }, + Val: &gnmipb.TypedValue{}, + }, + } + + err := Apply(context.Background(), nil, updates) + if err == nil { + t.Error("Expected error for bulk update without JSON") + } + }) + + t.Run("empty JSON value - single entry", func(t *testing.T) { + mr.FlushAll() + updates := []*gnmipb.Update{ + { + Path: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "CONFIG_DB"}, + {Name: "localhost"}, + {Name: "VNET"}, + {Name: "vnet1"}, + }, + }, + Val: &gnmipb.TypedValue{ + Value: &gnmipb.TypedValue_JsonIetfVal{ + JsonIetfVal: []byte(`{}`), + }, + }, + }, + } + + err := Apply(context.Background(), nil, updates) + if err != nil { + t.Errorf("Apply() error = %v, expected nil for empty JSON", err) + } + + // Key should exist with NULL placeholder (SONiC convention) + if !mr.Exists("VNET|vnet1") { + t.Error("Key should exist with NULL placeholder") + } + nullVal := mr.HGet("VNET|vnet1", "NULL") + if nullVal != "NULL" { + t.Errorf("Expected NULL=NULL, got %s", nullVal) + } + }) + + t.Run("empty JSON value - bulk update", func(t *testing.T) { + mr.FlushAll() + updates := []*gnmipb.Update{ + { + Path: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "CONFIG_DB"}, + {Name: "localhost"}, + {Name: "VNET"}, + }, + }, + Val: &gnmipb.TypedValue{ + Value: &gnmipb.TypedValue_JsonIetfVal{ + JsonIetfVal: []byte(`{"vnet1": {}, "vnet2": {"vni": "2000"}}`), + }, + }, + }, + } + + err := Apply(context.Background(), nil, updates) + if err != nil { + t.Errorf("Apply() error = %v", err) + } + + // vnet1 should exist with NULL placeholder + if !mr.Exists("VNET|vnet1") { + t.Error("VNET|vnet1 should exist with NULL placeholder") + } + nullVal := mr.HGet("VNET|vnet1", "NULL") + if nullVal != "NULL" { + t.Errorf("Expected NULL=NULL for vnet1, got %s", nullVal) + } + // vnet2 should exist with its field + vni := mr.HGet("VNET|vnet2", "vni") + if vni != "2000" { + t.Errorf("Expected vni=2000 for vnet2, got %s", vni) + } + }) +} + +func TestDelete(t *testing.T) { + mr := miniredis.RunT(t) + defer mr.Close() + + originalFunc := getConfigDbClientFunc + defer func() { getConfigDbClientFunc = originalFunc }() + + getConfigDbClientFunc = func() (*redis.Client, error) { + return redis.NewClient(&redis.Options{ + Addr: mr.Addr(), + DB: 0, + }), nil + } + + t.Run("delete existing key", func(t *testing.T) { + mr.FlushAll() + mr.HSet("VNET|vnet1", "vni", "1000") + + deletes := []*gnmipb.Path{ + { + Elem: []*gnmipb.PathElem{ + {Name: "CONFIG_DB"}, + {Name: "localhost"}, + {Name: "VNET"}, + {Name: "vnet1"}, + }, + }, + } + + err := Delete(context.Background(), nil, deletes) + if err != nil { + t.Errorf("Delete() error = %v", err) + } + + if mr.Exists("VNET|vnet1") { + t.Error("Expected key to be deleted") + } + }) + + t.Run("delete with invalid path - no key", func(t *testing.T) { + deletes := []*gnmipb.Path{ + { + Elem: []*gnmipb.PathElem{ + {Name: "CONFIG_DB"}, + {Name: "localhost"}, + {Name: "VNET"}, + }, + }, + } + + err := Delete(context.Background(), nil, deletes) + if err == nil { + t.Error("Expected error for invalid delete path") + } + }) +} + +func TestShouldBypass(t *testing.T) { + mr := miniredis.RunT(t) + defer mr.Close() + + originalFunc := getConfigDbClientFunc + defer func() { getConfigDbClientFunc = originalFunc }() + + getConfigDbClientFunc = func() (*redis.Client, error) { + return redis.NewClient(&redis.Options{ + Addr: mr.Addr(), + DB: 0, + }), nil + } + + // Set up allowed SKU + mr.HSet("DEVICE_METADATA|localhost", "hwsku", "Cisco-8102-test") + + t.Run("all conditions met", func(t *testing.T) { + ctx := metadata.NewIncomingContext( + context.Background(), + metadata.Pairs(MetadataKeyBypassValidation, "true"), + ) + updates := []*gnmipb.Update{ + { + Path: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "CONFIG_DB"}, + {Name: "localhost"}, + {Name: "VNET"}, + {Name: "vnet1"}, + }, + }, + }, + } + + result := ShouldBypass(ctx, nil, updates) + if !result { + t.Error("ShouldBypass() should return true when all conditions met") + } + }) + + t.Run("no bypass header", func(t *testing.T) { + ctx := context.Background() + updates := []*gnmipb.Update{ + { + Path: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "VNET"}, + {Name: "vnet1"}, + }, + }, + }, + } + + result := ShouldBypass(ctx, nil, updates) + if result { + t.Error("ShouldBypass() should return false without bypass header") + } + }) + + t.Run("disallowed table", func(t *testing.T) { + ctx := metadata.NewIncomingContext( + context.Background(), + metadata.Pairs(MetadataKeyBypassValidation, "true"), + ) + updates := []*gnmipb.Update{ + { + Path: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "PORT"}, + {Name: "Ethernet0"}, + }, + }, + }, + } + + result := ShouldBypass(ctx, nil, updates) + if result { + t.Error("ShouldBypass() should return false for disallowed table") + } + }) +} + +func TestShouldBypassDelete(t *testing.T) { + mr := miniredis.RunT(t) + defer mr.Close() + + originalFunc := getConfigDbClientFunc + defer func() { getConfigDbClientFunc = originalFunc }() + + getConfigDbClientFunc = func() (*redis.Client, error) { + return redis.NewClient(&redis.Options{ + Addr: mr.Addr(), + DB: 0, + }), nil + } + + // Set up allowed SKU + mr.HSet("DEVICE_METADATA|localhost", "hwsku", "Cisco-8102-test") + + t.Run("all conditions met", func(t *testing.T) { + ctx := metadata.NewIncomingContext( + context.Background(), + metadata.Pairs(MetadataKeyBypassValidation, "true"), + ) + deletes := []*gnmipb.Path{ + { + Elem: []*gnmipb.PathElem{ + {Name: "CONFIG_DB"}, + {Name: "localhost"}, + {Name: "VNET"}, + {Name: "vnet1"}, + }, + }, + } + + result := ShouldBypassDelete(ctx, nil, deletes) + if !result { + t.Error("ShouldBypassDelete() should return true when all conditions met") + } + }) + + t.Run("disallowed table", func(t *testing.T) { + ctx := metadata.NewIncomingContext( + context.Background(), + metadata.Pairs(MetadataKeyBypassValidation, "true"), + ) + deletes := []*gnmipb.Path{ + { + Elem: []*gnmipb.PathElem{ + {Name: "INTERFACE"}, + {Name: "Ethernet0"}, + }, + }, + } + + result := ShouldBypassDelete(ctx, nil, deletes) + if result { + t.Error("ShouldBypassDelete() should return false for disallowed table") + } + }) +} + +func TestAllowedTables(t *testing.T) { + expectedTables := []string{"VNET", "VNET_ROUTE_TUNNEL", "VLAN_SUB_INTERFACE", "ACL_RULE", "BGP_PEER_RANGE", "BGP_NEIGHBOR", "PREFIX_LIST"} + + for _, table := range expectedTables { + if !AllowedTables[table] { + t.Errorf("Expected %s to be in AllowedTables", table) + } + } + + disallowedTables := []string{"PORT", "INTERFACE", "VLAN", "LOOPBACK_INTERFACE"} + for _, table := range disallowedTables { + if AllowedTables[table] { + t.Errorf("Expected %s to NOT be in AllowedTables", table) + } + } +} + +func TestAllowedSKUPrefixes(t *testing.T) { + expectedPrefixes := []string{"Cisco-8102", "Cisco-8101", "Cisco-8223"} + + if len(AllowedSKUPrefixes) != len(expectedPrefixes) { + t.Errorf("Expected %d SKU prefixes, got %d", len(expectedPrefixes), len(AllowedSKUPrefixes)) + } + + for i, prefix := range expectedPrefixes { + if AllowedSKUPrefixes[i] != prefix { + t.Errorf("Expected prefix %s at index %d, got %s", prefix, i, AllowedSKUPrefixes[i]) + } + } +} + +func TestApplyScalarField(t *testing.T) { + mr := miniredis.RunT(t) + defer mr.Close() + + originalFunc := getConfigDbClientFunc + defer func() { getConfigDbClientFunc = originalFunc }() + + getConfigDbClientFunc = func() (*redis.Client, error) { + return redis.NewClient(&redis.Options{ + Addr: mr.Addr(), + DB: 0, + }), nil + } + + t.Run("scalar string field update", func(t *testing.T) { + mr.FlushAll() + updates := []*gnmipb.Update{ + { + Path: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "CONFIG_DB"}, + {Name: "localhost"}, + {Name: "VNET"}, + {Name: "vnet1"}, + {Name: "vni"}, + }, + }, + Val: &gnmipb.TypedValue{ + Value: &gnmipb.TypedValue_StringVal{ + StringVal: "5000", + }, + }, + }, + } + + err := Apply(context.Background(), nil, updates) + if err != nil { + t.Errorf("Apply() error = %v", err) + } + + vni := mr.HGet("VNET|vnet1", "vni") + if vni != "5000" { + t.Errorf("Expected vni=5000, got %s", vni) + } + }) + + t.Run("scalar int field update", func(t *testing.T) { + mr.FlushAll() + updates := []*gnmipb.Update{ + { + Path: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "CONFIG_DB"}, + {Name: "localhost"}, + {Name: "VNET"}, + {Name: "vnet1"}, + {Name: "priority"}, + }, + }, + Val: &gnmipb.TypedValue{ + Value: &gnmipb.TypedValue_IntVal{ + IntVal: 100, + }, + }, + }, + } + + err := Apply(context.Background(), nil, updates) + if err != nil { + t.Errorf("Apply() error = %v", err) + } + + priority := mr.HGet("VNET|vnet1", "priority") + if priority != "100" { + t.Errorf("Expected priority=100, got %s", priority) + } + }) + + t.Run("scalar uint field update", func(t *testing.T) { + mr.FlushAll() + updates := []*gnmipb.Update{ + { + Path: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "CONFIG_DB"}, + {Name: "localhost"}, + {Name: "VNET"}, + {Name: "vnet1"}, + {Name: "counter"}, + }, + }, + Val: &gnmipb.TypedValue{ + Value: &gnmipb.TypedValue_UintVal{ + UintVal: 999, + }, + }, + }, + } + + err := Apply(context.Background(), nil, updates) + if err != nil { + t.Errorf("Apply() error = %v", err) + } + + counter := mr.HGet("VNET|vnet1", "counter") + if counter != "999" { + t.Errorf("Expected counter=999, got %s", counter) + } + }) +} + +func TestApplyErrors(t *testing.T) { + t.Run("client error", func(t *testing.T) { + originalFunc := getConfigDbClientFunc + defer func() { getConfigDbClientFunc = originalFunc }() + + getConfigDbClientFunc = func() (*redis.Client, error) { + return nil, fmt.Errorf("connection refused") + } + + updates := []*gnmipb.Update{ + { + Path: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "VNET"}, + {Name: "vnet1"}, + }, + }, + Val: &gnmipb.TypedValue{ + Value: &gnmipb.TypedValue_JsonIetfVal{ + JsonIetfVal: []byte(`{"vni": "1000"}`), + }, + }, + }, + } + + err := Apply(context.Background(), nil, updates) + if err == nil { + t.Error("Expected error for client failure") + } + }) + + t.Run("invalid JSON in bulk update", func(t *testing.T) { + mr := miniredis.RunT(t) + defer mr.Close() + + originalFunc := getConfigDbClientFunc + defer func() { getConfigDbClientFunc = originalFunc }() + + getConfigDbClientFunc = func() (*redis.Client, error) { + return redis.NewClient(&redis.Options{ + Addr: mr.Addr(), + DB: 0, + }), nil + } + + updates := []*gnmipb.Update{ + { + Path: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "CONFIG_DB"}, + {Name: "localhost"}, + {Name: "VNET"}, + }, + }, + Val: &gnmipb.TypedValue{ + Value: &gnmipb.TypedValue_JsonIetfVal{ + JsonIetfVal: []byte(`{invalid json`), + }, + }, + }, + } + + err := Apply(context.Background(), nil, updates) + if err == nil { + t.Error("Expected error for invalid JSON") + } + }) + + t.Run("invalid JSON in single entry", func(t *testing.T) { + mr := miniredis.RunT(t) + defer mr.Close() + + originalFunc := getConfigDbClientFunc + defer func() { getConfigDbClientFunc = originalFunc }() + + getConfigDbClientFunc = func() (*redis.Client, error) { + return redis.NewClient(&redis.Options{ + Addr: mr.Addr(), + DB: 0, + }), nil + } + + updates := []*gnmipb.Update{ + { + Path: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "CONFIG_DB"}, + {Name: "localhost"}, + {Name: "VNET"}, + {Name: "vnet1"}, + }, + }, + Val: &gnmipb.TypedValue{ + Value: &gnmipb.TypedValue_JsonIetfVal{ + JsonIetfVal: []byte(`not valid json`), + }, + }, + }, + } + + err := Apply(context.Background(), nil, updates) + if err == nil { + t.Error("Expected error for invalid JSON") + } + }) +} + +func TestDeleteErrors(t *testing.T) { + t.Run("client error", func(t *testing.T) { + originalFunc := getConfigDbClientFunc + defer func() { getConfigDbClientFunc = originalFunc }() + + getConfigDbClientFunc = func() (*redis.Client, error) { + return nil, fmt.Errorf("connection refused") + } + + deletes := []*gnmipb.Path{ + { + Elem: []*gnmipb.PathElem{ + {Name: "VNET"}, + {Name: "vnet1"}, + }, + }, + } + + err := Delete(context.Background(), nil, deletes) + if err == nil { + t.Error("Expected error for client failure") + } + }) +} + +func TestCheckSKUError(t *testing.T) { + originalFunc := getConfigDbClientFunc + defer func() { getConfigDbClientFunc = originalFunc }() + + getConfigDbClientFunc = func() (*redis.Client, error) { + return nil, fmt.Errorf("connection refused") + } + + result := checkSKU() + if result { + t.Error("checkSKU() should return false when client fails") + } +} + +func TestExtractTableWithPrefix(t *testing.T) { + prefix := &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "CONFIG_DB"}, + {Name: "localhost"}, + }, + } + path := &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "VNET"}, + {Name: "vnet1"}, + }, + } + + table := extractTable(prefix, path) + if table != "VNET" { + t.Errorf("Expected table=VNET, got %s", table) + } +} + +func TestCheckAllowedDeletePathsEmptyTable(t *testing.T) { + deletes := []*gnmipb.Path{ + { + Elem: []*gnmipb.PathElem{}, + }, + } + + result := checkAllowedDeletePaths(nil, deletes) + if result { + t.Error("checkAllowedDeletePaths() should return false for empty path") + } +} + +func TestCheckAllowedTablesEmptyTable(t *testing.T) { + updates := []*gnmipb.Update{ + { + Path: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{}, + }, + }, + } + + result := checkAllowedTables(nil, updates) + if result { + t.Error("checkAllowedTables() should return false for empty path") + } +} + +func TestGetConfigDbClientDefault_EnvUnixSocket(t *testing.T) { + // Save and restore original env + originalAddr := os.Getenv("REDIS_ADDR") + defer os.Setenv("REDIS_ADDR", originalAddr) + + // Set REDIS_ADDR to unix socket path + os.Setenv("REDIS_ADDR", "/var/run/redis/test.sock") + + client, err := getConfigDbClientDefault() + if err != nil { + t.Errorf("getConfigDbClientDefault() error = %v", err) + } + if client == nil { + t.Fatal("Expected non-nil client") + } + defer client.Close() + + // Client should be configured for unix socket + opts := client.Options() + if opts.Network != "unix" { + t.Errorf("Expected network=unix, got %s", opts.Network) + } + if opts.Addr != "/var/run/redis/test.sock" { + t.Errorf("Expected addr=/var/run/redis/test.sock, got %s", opts.Addr) + } + if opts.DB != configDbId { + t.Errorf("Expected DB=%d, got %d", configDbId, opts.DB) + } +} + +func TestGetConfigDbClientDefault_EnvTcpAddress(t *testing.T) { + // Save and restore original env + originalAddr := os.Getenv("REDIS_ADDR") + defer os.Setenv("REDIS_ADDR", originalAddr) + + // Set REDIS_ADDR to TCP address + os.Setenv("REDIS_ADDR", "192.168.1.100:6380") + + client, err := getConfigDbClientDefault() + if err != nil { + t.Errorf("getConfigDbClientDefault() error = %v", err) + } + if client == nil { + t.Fatal("Expected non-nil client") + } + defer client.Close() + + // Client should be configured for TCP + opts := client.Options() + if opts.Network != "tcp" { + t.Errorf("Expected network=tcp, got %s", opts.Network) + } + if opts.Addr != "192.168.1.100:6380" { + t.Errorf("Expected addr=192.168.1.100:6380, got %s", opts.Addr) + } + if opts.DB != configDbId { + t.Errorf("Expected DB=%d, got %d", configDbId, opts.DB) + } +} + +func TestGetConfigDbClientDefault_FallbackToTcp(t *testing.T) { + // Save and restore original env + originalAddr := os.Getenv("REDIS_ADDR") + defer os.Setenv("REDIS_ADDR", originalAddr) + + // Clear REDIS_ADDR to trigger auto-detection + os.Unsetenv("REDIS_ADDR") + + // The default unix socket /var/run/redis/redis.sock typically doesn't exist in test env + // so it should fall back to TCP + client, err := getConfigDbClientDefault() + if err != nil { + t.Errorf("getConfigDbClientDefault() error = %v", err) + } + if client == nil { + t.Fatal("Expected non-nil client") + } + defer client.Close() + + opts := client.Options() + // Either unix or tcp is acceptable - depends on whether /var/run/redis/redis.sock exists + if opts.Network != "unix" && opts.Network != "tcp" { + t.Errorf("Expected network=unix or tcp, got %s", opts.Network) + } + if opts.DB != configDbId { + t.Errorf("Expected DB=%d, got %d", configDbId, opts.DB) + } +} + +func TestGetConfigDbClientDefault_UnixSocketExists(t *testing.T) { + // Save and restore original env + originalAddr := os.Getenv("REDIS_ADDR") + defer os.Setenv("REDIS_ADDR", originalAddr) + + // Clear REDIS_ADDR + os.Unsetenv("REDIS_ADDR") + + // Create a temporary file to simulate the unix socket existing + tmpDir := t.TempDir() + tmpSocket := tmpDir + "/redis.sock" + f, err := os.Create(tmpSocket) + if err != nil { + t.Fatalf("Failed to create temp socket file: %v", err) + } + f.Close() + + // Temporarily override the default socket path for testing + // Since we can't easily override constants, we'll test the logic indirectly + // by checking behavior with REDIS_ADDR set to a path that exists + os.Setenv("REDIS_ADDR", tmpSocket) + + client, err := getConfigDbClientDefault() + if err != nil { + t.Errorf("getConfigDbClientDefault() error = %v", err) + } + if client == nil { + t.Fatal("Expected non-nil client") + } + defer client.Close() + + opts := client.Options() + if opts.Network != "unix" { + t.Errorf("Expected network=unix for path starting with /, got %s", opts.Network) + } + if opts.Addr != tmpSocket { + t.Errorf("Expected addr=%s, got %s", tmpSocket, opts.Addr) + } +} + +func TestGetConfigDbClientDefault_ConfigDbId(t *testing.T) { + // Verify the constant is set correctly for CONFIG_DB + if configDbId != 4 { + t.Errorf("Expected configDbId=4 (CONFIG_DB), got %d", configDbId) + } +} + +func TestGetConfigDbClientDefault_DefaultConstants(t *testing.T) { + // Verify default constants are set correctly + if defaultRedisSocket != "/var/run/redis/redis.sock" { + t.Errorf("Expected defaultRedisSocket=/var/run/redis/redis.sock, got %s", defaultRedisSocket) + } + if defaultRedisTCP != "127.0.0.1:6379" { + t.Errorf("Expected defaultRedisTCP=127.0.0.1:6379, got %s", defaultRedisTCP) + } +} + +func TestTrySet(t *testing.T) { + mr := miniredis.RunT(t) + defer mr.Close() + + originalFunc := getConfigDbClientFunc + defer func() { getConfigDbClientFunc = originalFunc }() + + getConfigDbClientFunc = func() (*redis.Client, error) { + return redis.NewClient(&redis.Options{ + Addr: mr.Addr(), + DB: 0, + }), nil + } + + // Set up allowed SKU + mr.HSet("DEVICE_METADATA|localhost", "hwsku", "Cisco-8102-test") + + t.Run("bypass conditions not met - no header", func(t *testing.T) { + ctx := context.Background() + updates := []*gnmipb.Update{ + { + Path: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{{Name: "VNET"}, {Name: "vnet1"}}, + }, + Val: &gnmipb.TypedValue{ + Value: &gnmipb.TypedValue_JsonIetfVal{JsonIetfVal: []byte(`{"vni": "1000"}`)}, + }, + }, + } + + resp, used, err := TrySet(ctx, nil, nil, updates) + if used { + t.Error("TrySet should return used=false when bypass header missing") + } + if resp != nil { + t.Error("Expected nil response when bypass not used") + } + if err != nil { + t.Errorf("Expected nil error, got %v", err) + } + }) + + t.Run("bypass conditions not met - disallowed table", func(t *testing.T) { + ctx := metadata.NewIncomingContext( + context.Background(), + metadata.Pairs(MetadataKeyBypassValidation, "true"), + ) + updates := []*gnmipb.Update{ + { + Path: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{{Name: "PORT"}, {Name: "Ethernet0"}}, + }, + Val: &gnmipb.TypedValue{ + Value: &gnmipb.TypedValue_JsonIetfVal{JsonIetfVal: []byte(`{"speed": "100000"}`)}, + }, + }, + } + + resp, used, err := TrySet(ctx, nil, nil, updates) + if used { + t.Error("TrySet should return used=false for disallowed table") + } + if resp != nil { + t.Error("Expected nil response when bypass not used") + } + if err != nil { + t.Errorf("Expected nil error, got %v", err) + } + }) + + t.Run("bypass with updates only", func(t *testing.T) { + mr.FlushAll() + mr.HSet("DEVICE_METADATA|localhost", "hwsku", "Cisco-8102-test") + + ctx := metadata.NewIncomingContext( + context.Background(), + metadata.Pairs(MetadataKeyBypassValidation, "true"), + ) + updates := []*gnmipb.Update{ + { + Path: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{{Name: "VNET"}, {Name: "vnet1"}}, + }, + Val: &gnmipb.TypedValue{ + Value: &gnmipb.TypedValue_JsonIetfVal{JsonIetfVal: []byte(`{"vni": "1000"}`)}, + }, + }, + } + + resp, used, err := TrySet(ctx, nil, nil, updates) + if !used { + t.Error("TrySet should return used=true when bypass conditions met") + } + if err != nil { + t.Errorf("Expected nil error, got %v", err) + } + if resp == nil { + t.Fatal("Expected non-nil response") + } + if len(resp.Response) != 1 { + t.Errorf("Expected 1 result, got %d", len(resp.Response)) + } + if resp.Response[0].Op != gnmipb.UpdateResult_UPDATE { + t.Errorf("Expected UPDATE op, got %v", resp.Response[0].Op) + } + + // Verify data was written + vni := mr.HGet("VNET|vnet1", "vni") + if vni != "1000" { + t.Errorf("Expected vni=1000, got %s", vni) + } + }) + + t.Run("bypass with deletes only", func(t *testing.T) { + mr.FlushAll() + mr.HSet("DEVICE_METADATA|localhost", "hwsku", "Cisco-8102-test") + mr.HSet("VNET|vnet1", "vni", "1000") + + ctx := metadata.NewIncomingContext( + context.Background(), + metadata.Pairs(MetadataKeyBypassValidation, "true"), + ) + deletes := []*gnmipb.Path{ + { + Elem: []*gnmipb.PathElem{{Name: "VNET"}, {Name: "vnet1"}}, + }, + } + + resp, used, err := TrySet(ctx, nil, deletes, nil) + if !used { + t.Error("TrySet should return used=true when bypass conditions met") + } + if err != nil { + t.Errorf("Expected nil error, got %v", err) + } + if resp == nil { + t.Fatal("Expected non-nil response") + } + if len(resp.Response) != 1 { + t.Errorf("Expected 1 result, got %d", len(resp.Response)) + } + if resp.Response[0].Op != gnmipb.UpdateResult_DELETE { + t.Errorf("Expected DELETE op, got %v", resp.Response[0].Op) + } + + // Verify data was deleted + if mr.Exists("VNET|vnet1") { + t.Error("Expected key to be deleted") + } + }) + + t.Run("bypass with mixed deletes and updates", func(t *testing.T) { + mr.FlushAll() + mr.HSet("DEVICE_METADATA|localhost", "hwsku", "Cisco-8102-test") + mr.HSet("VNET|vnet_old", "vni", "999") + + ctx := metadata.NewIncomingContext( + context.Background(), + metadata.Pairs(MetadataKeyBypassValidation, "true"), + ) + deletes := []*gnmipb.Path{ + { + Elem: []*gnmipb.PathElem{{Name: "VNET"}, {Name: "vnet_old"}}, + }, + } + updates := []*gnmipb.Update{ + { + Path: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{{Name: "VNET"}, {Name: "vnet_new"}}, + }, + Val: &gnmipb.TypedValue{ + Value: &gnmipb.TypedValue_JsonIetfVal{JsonIetfVal: []byte(`{"vni": "2000"}`)}, + }, + }, + } + + resp, used, err := TrySet(ctx, nil, deletes, updates) + if !used { + t.Error("TrySet should return used=true") + } + if err != nil { + t.Errorf("Expected nil error, got %v", err) + } + if resp == nil { + t.Fatal("Expected non-nil response") + } + if len(resp.Response) != 2 { + t.Errorf("Expected 2 results, got %d", len(resp.Response)) + } + + // Verify delete happened (first per gNMI spec) + if mr.Exists("VNET|vnet_old") { + t.Error("Expected old key to be deleted") + } + // Verify update happened + vni := mr.HGet("VNET|vnet_new", "vni") + if vni != "2000" { + t.Errorf("Expected vni=2000, got %s", vni) + } + }) + + t.Run("empty operations", func(t *testing.T) { + ctx := metadata.NewIncomingContext( + context.Background(), + metadata.Pairs(MetadataKeyBypassValidation, "true"), + ) + + resp, used, err := TrySet(ctx, nil, nil, nil) + if used { + t.Error("TrySet should return used=false for empty operations") + } + if resp != nil { + t.Error("Expected nil response") + } + if err != nil { + t.Errorf("Expected nil error, got %v", err) + } + }) +} + +func TestTrySetErrors(t *testing.T) { + t.Run("update error returns used=true with error", func(t *testing.T) { + originalFunc := getConfigDbClientFunc + defer func() { getConfigDbClientFunc = originalFunc }() + + getConfigDbClientFunc = func() (*redis.Client, error) { + return nil, fmt.Errorf("connection refused") + } + + ctx := metadata.NewIncomingContext( + context.Background(), + metadata.Pairs(MetadataKeyBypassValidation, "true"), + ) + updates := []*gnmipb.Update{ + { + Path: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{{Name: "VNET"}, {Name: "vnet1"}}, + }, + Val: &gnmipb.TypedValue{ + Value: &gnmipb.TypedValue_JsonIetfVal{JsonIetfVal: []byte(`{"vni": "1000"}`)}, + }, + }, + } + + // Need to mock checkSKU to pass - but it will fail on Apply + // For this test, we need the SKU check to pass first + mr := miniredis.RunT(t) + defer mr.Close() + + // First call succeeds (SKU check), second fails (Apply) + callCount := 0 + getConfigDbClientFunc = func() (*redis.Client, error) { + callCount++ + if callCount == 1 { + // SKU check succeeds + return redis.NewClient(&redis.Options{Addr: mr.Addr(), DB: 0}), nil + } + // Apply fails + return nil, fmt.Errorf("connection refused") + } + mr.HSet("DEVICE_METADATA|localhost", "hwsku", "Cisco-8102-test") + + resp, used, err := TrySet(ctx, nil, nil, updates) + if !used { + t.Error("TrySet should return used=true when bypass was attempted") + } + if err == nil { + t.Error("Expected error") + } + if resp != nil { + t.Error("Expected nil response on error") + } + }) +} + +func TestConvertToRedisFields(t *testing.T) { + tests := []struct { + name string + input map[string]interface{} + expected map[string]interface{} + }{ + { + name: "scalar string value", + input: map[string]interface{}{ + "name": "test", + }, + expected: map[string]interface{}{ + "name": "test", + }, + }, + { + name: "scalar int value", + input: map[string]interface{}{ + "vni": 1000, + }, + expected: map[string]interface{}{ + "vni": "1000", + }, + }, + { + name: "list value - SONiC convention", + input: map[string]interface{}{ + "ip_range": []interface{}{"10.0.1.0/24", "10.0.2.0/24"}, + }, + expected: map[string]interface{}{ + "ip_range@": "10.0.1.0/24,10.0.2.0/24", + }, + }, + { + name: "mixed scalar and list values", + input: map[string]interface{}{ + "name": "WLPARTNER_PASSIVE_V4", + "peer_asn": "4210000062", + "ip_range": []interface{}{"10.0.1.0/24", "10.0.2.0/24"}, + }, + expected: map[string]interface{}{ + "name": "WLPARTNER_PASSIVE_V4", + "peer_asn": "4210000062", + "ip_range@": "10.0.1.0/24,10.0.2.0/24", + }, + }, + { + name: "empty list", + input: map[string]interface{}{ + "ports": []interface{}{}, + }, + expected: map[string]interface{}{ + "ports@": "", + }, + }, + { + name: "single item list", + input: map[string]interface{}{ + "members": []interface{}{"Ethernet0"}, + }, + expected: map[string]interface{}{ + "members@": "Ethernet0", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := convertToRedisFields(tt.input) + if len(result) != len(tt.expected) { + t.Errorf("convertToRedisFields() returned %d fields, want %d", len(result), len(tt.expected)) + } + for k, v := range tt.expected { + if result[k] != v { + t.Errorf("convertToRedisFields()[%s] = %v, want %v", k, result[k], v) + } + } + }) + } +} + +func TestApplyWithListFields(t *testing.T) { + mr := miniredis.RunT(t) + defer mr.Close() + + originalFunc := getConfigDbClientFunc + defer func() { getConfigDbClientFunc = originalFunc }() + + getConfigDbClientFunc = func() (*redis.Client, error) { + return redis.NewClient(&redis.Options{ + Addr: mr.Addr(), + DB: 0, + }), nil + } + + t.Run("BGP_PEER_RANGE with ip_range list - single entry", func(t *testing.T) { + mr.FlushAll() + // This mimics the exact request from the bug report + updates := []*gnmipb.Update{ + { + Path: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "CONFIG_DB"}, + {Name: "localhost"}, + {Name: "BGP_PEER_RANGE"}, + {Name: "Vnet_1000|WLPARTNER_PASSIVE_V4"}, + }, + }, + Val: &gnmipb.TypedValue{ + Value: &gnmipb.TypedValue_JsonIetfVal{ + JsonIetfVal: []byte(`{"name":"WLPARTNER_PASSIVE_V4","peer_asn":"4210000062","ip_range":["10.0.1.0/24", "10.0.2.0/24"]}`), + }, + }, + }, + } + + err := Apply(context.Background(), nil, updates) + if err != nil { + t.Errorf("Apply() error = %v", err) + } + + // Verify SONiC convention: ip_range@ with comma-separated values + ipRange := mr.HGet("BGP_PEER_RANGE|Vnet_1000|WLPARTNER_PASSIVE_V4", "ip_range@") + expectedIpRange := "10.0.1.0/24,10.0.2.0/24" + if ipRange != expectedIpRange { + t.Errorf("Expected ip_range@=%s, got %s", expectedIpRange, ipRange) + } + + // Verify scalar fields remain unchanged + name := mr.HGet("BGP_PEER_RANGE|Vnet_1000|WLPARTNER_PASSIVE_V4", "name") + if name != "WLPARTNER_PASSIVE_V4" { + t.Errorf("Expected name=WLPARTNER_PASSIVE_V4, got %s", name) + } + + peerAsn := mr.HGet("BGP_PEER_RANGE|Vnet_1000|WLPARTNER_PASSIVE_V4", "peer_asn") + if peerAsn != "4210000062" { + t.Errorf("Expected peer_asn=4210000062, got %s", peerAsn) + } + + // Verify ip_range (without @) does NOT exist + badIpRange := mr.HGet("BGP_PEER_RANGE|Vnet_1000|WLPARTNER_PASSIVE_V4", "ip_range") + if badIpRange != "" { + t.Errorf("ip_range (without @) should not exist, got %s", badIpRange) + } + }) + + t.Run("bulk update with list fields", func(t *testing.T) { + mr.FlushAll() + updates := []*gnmipb.Update{ + { + Path: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "CONFIG_DB"}, + {Name: "localhost"}, + {Name: "BGP_PEER_RANGE"}, + }, + }, + Val: &gnmipb.TypedValue{ + Value: &gnmipb.TypedValue_JsonIetfVal{ + JsonIetfVal: []byte(`{ + "Vnet_1000|PEER_V4": {"name":"PEER_V4","ip_range":["10.1.0.0/24"]}, + "Vnet_2000|PEER_V6": {"name":"PEER_V6","ip_range":["10.2.0.0/24","10.3.0.0/24"]} + }`), + }, + }, + }, + } + + err := Apply(context.Background(), nil, updates) + if err != nil { + t.Errorf("Apply() error = %v", err) + } + + // Verify first entry + ipRange1 := mr.HGet("BGP_PEER_RANGE|Vnet_1000|PEER_V4", "ip_range@") + if ipRange1 != "10.1.0.0/24" { + t.Errorf("Expected ip_range@=10.1.0.0/24, got %s", ipRange1) + } + + // Verify second entry + ipRange2 := mr.HGet("BGP_PEER_RANGE|Vnet_2000|PEER_V6", "ip_range@") + if ipRange2 != "10.2.0.0/24,10.3.0.0/24" { + t.Errorf("Expected ip_range@=10.2.0.0/24,10.3.0.0/24, got %s", ipRange2) + } + }) +} diff --git a/pure.mk b/pure.mk index 7dd93e9b..999e69b7 100644 --- a/pure.mk +++ b/pure.mk @@ -16,6 +16,7 @@ GOROOT ?= $(shell $(GO) env GOROOT) PURE_PACKAGES := \ internal/exec \ pkg/gnoi/debug \ + pkg/bypass \ internal/diskspace \ internal/hash \ internal/download \