diff --git a/api/grpc/mpi/v1/helpers.go b/api/grpc/mpi/v1/helpers.go new file mode 100644 index 000000000..02a5adb83 --- /dev/null +++ b/api/grpc/mpi/v1/helpers.go @@ -0,0 +1,47 @@ +// Copyright (c) F5, Inc. +// +// This source code is licensed under the Apache License, Version 2.0 license found in the +// LICENSE file in the root directory of this source tree. + +package v1 + +import ( + "google.golang.org/protobuf/types/known/structpb" +) + +// ConvertToStructs converts a map[string]any into a slice of *structpb.Struct. +// Each key-value pair in the input map is converted into a *structpb.Struct, +// where the key is used as the field name, and the value is added to the Struct. +// +// Parameters: +// - input: A map[string]any containing key-value pairs to be converted. +// +// Returns: +// - []*structpb.Struct: A slice of *structpb.Struct, where each map entry is converted into a struct. +// - error: An error if any value in the input map cannot be converted into a *structpb.Struct. +// +// Example: +// +// input := map[string]any{ +// "key1": "value1", +// "key2": 123, +// "key3": true, +// } +// structs, err := ConvertToStructs(input) +// // structs will contain a slice of *structpb.Struct +// // err will be nil if all conversions succeed. +func ConvertToStructs(input map[string]any) ([]*structpb.Struct, error) { + structs := []*structpb.Struct{} + for key, value := range input { + // Convert each value in the map to *structpb.Struct + structValue, err := structpb.NewStruct(map[string]any{ + key: value, + }) + if err != nil { + return structs, err + } + structs = append(structs, structValue) + } + + return structs, nil +} diff --git a/api/grpc/mpi/v1/helpers_test.go b/api/grpc/mpi/v1/helpers_test.go new file mode 100644 index 000000000..62cfb0a90 --- /dev/null +++ b/api/grpc/mpi/v1/helpers_test.go @@ -0,0 +1,72 @@ +// Copyright (c) F5, Inc. +// +// This source code is licensed under the Apache License, Version 2.0 license found in the +// LICENSE file in the root directory of this source tree. + +package v1 + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/types/known/structpb" +) + +func TestConvertToStructs(t *testing.T) { + tests := []struct { + name string + input map[string]any + expected []*structpb.Struct + wantErr bool + }{ + { + name: "Test 1: Valid input with simple key-value pairs", + input: map[string]any{ + "key1": "value1", + "key2": 123, + "key3": true, + }, + expected: []*structpb.Struct{ + { + Fields: map[string]*structpb.Value{ + "key1": structpb.NewStringValue("value1"), + }, + }, + { + Fields: map[string]*structpb.Value{ + "key2": structpb.NewNumberValue(123), + }, + }, + { + Fields: map[string]*structpb.Value{ + "key3": structpb.NewBoolValue(true), + }, + }, + }, + wantErr: false, + }, + { + name: "Test 2: Empty input map", + input: make(map[string]any), + expected: []*structpb.Struct{}, + wantErr: false, + }, + { + name: "Test 3: Invalid input type", + input: map[string]any{ + "key1": func() {}, // Unsupported type + }, + expected: []*structpb.Struct{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ConvertToStructs(tt.input) + + assert.Equal(t, tt.expected, got) + assert.Equal(t, tt.wantErr, err != nil) + }) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 4bd76679c..e35a60b78 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,12 +7,14 @@ package config import ( "context" + "encoding/json" "errors" "fmt" "log/slog" "os" "path/filepath" "slices" + "strconv" "strings" "time" @@ -104,6 +106,7 @@ func ResolveConfig() (*Config, error) { Common: resolveCommon(), Watchers: resolveWatchers(), Features: viperInstance.GetStringSlice(FeaturesKey), + Labels: resolveLabels(), } slog.Debug("Agent config", "config", config) @@ -202,6 +205,7 @@ func registerFlags() { "A comma-separated list of features enabled for the agent.", ) + registerCommonFlags(fs) registerCommandFlags(fs) registerCollectorFlags(fs) @@ -218,6 +222,14 @@ func registerFlags() { }) } +func registerCommonFlags(fs *flag.FlagSet) { + fs.StringToString( + LabelsRootKey, + DefaultLabels(), + "A list of labels associated with these instances", + ) +} + func registerCommandFlags(fs *flag.FlagSet) { fs.String( CommandServerHostKey, @@ -406,6 +418,71 @@ func resolveLog() *Log { } } +func resolveLabels() map[string]interface{} { + input := viperInstance.GetStringMapString(LabelsRootKey) + result := make(map[string]interface{}) + + for key, value := range input { + trimmedValue := strings.TrimSpace(value) + + switch { + case trimmedValue == "" || trimmedValue == "nil": // Handle empty values as nil + result[key] = nil + + case parseInt(trimmedValue) != nil: // Integer + result[key] = parseInt(trimmedValue) + + case parseFloat(trimmedValue) != nil: // Float + result[key] = parseFloat(trimmedValue) + + case parseBool(trimmedValue) != nil: // Boolean + result[key] = parseBool(trimmedValue) + + case parseJSON(trimmedValue) != nil: // JSON object/array + result[key] = parseJSON(trimmedValue) + + default: // String + result[key] = trimmedValue + } + } + + return result +} + +// Parsing helper functions return the parsed value or nil if parsing fails +func parseInt(value string) interface{} { + if intValue, err := strconv.Atoi(value); err == nil { + return intValue + } + + return nil +} + +func parseFloat(value string) interface{} { + if floatValue, err := strconv.ParseFloat(value, 64); err == nil { + return floatValue + } + + return nil +} + +func parseBool(value string) interface{} { + if boolValue, err := strconv.ParseBool(value); err == nil { + return boolValue + } + + return nil +} + +func parseJSON(value string) interface{} { + var jsonValue interface{} + if err := json.Unmarshal([]byte(value), &jsonValue); err == nil { + return jsonValue + } + + return nil +} + func resolveDataPlaneConfig() *DataPlaneConfig { return &DataPlaneConfig{ Nginx: &NginxDataPlaneConfig{ diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 9cfb9d36e..92d21dcf6 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -7,6 +7,7 @@ package config import ( "os" "path" + "strings" "testing" "time" @@ -107,6 +108,7 @@ func TestRegisterFlags(t *testing.T) { assert.Equal(t, "warn", viperInstance.GetString(LogLevelKey)) assert.Equal(t, "/var/log/test/agent.log", viperInstance.GetString(LogPathKey)) assert.Equal(t, 10*time.Second, viperInstance.GetDuration(ClientTimeoutKey)) + assert.Equal(t, make(map[string]string), viperInstance.GetStringMapString(LabelsRootKey)) } func TestSeekFileInPaths(t *testing.T) { @@ -287,6 +289,313 @@ func TestClient(t *testing.T) { assert.Equal(t, expected, result) } +func TestResolveLabels(t *testing.T) { + // Helper to set up the viper instance + setupViper := func(input map[string]string) { + viperInstance = viper.New() // Create a new viper instance for isolation + viperInstance.Set(LabelsRootKey, input) + } + + tests := []struct { + input map[string]string + expected map[string]interface{} + name string + }{ + { + name: "Test 1: Integer values", + input: map[string]string{ + "key1": "123", + "key2": "456", + }, + expected: map[string]interface{}{ + "key1": 123, + "key2": 456, + }, + }, + { + name: "Test 2: Float values", + input: map[string]string{ + "key1": "123.45", + "key2": "678.90", + }, + expected: map[string]interface{}{ + "key1": 123.45, + "key2": 678.9, + }, + }, + { + name: "Test 3: Boolean values", + input: map[string]string{ + "key1": "true", + "key2": "false", + }, + expected: map[string]interface{}{ + "key1": true, + "key2": false, + }, + }, + { + name: "Test 4: Mixed types", + input: map[string]string{ + "key1": "true", + "key2": "123", + "key3": "45.67", + "key4": "hello", + }, + expected: map[string]interface{}{ + "key1": true, + "key2": 123, + "key3": 45.67, + "key4": "hello", + }, + }, + { + name: "Test 5: String values", + input: map[string]string{ + "key1": "hello", + "key2": "world", + }, + expected: map[string]interface{}{ + "key1": "hello", + "key2": "world", + }, + }, + { + name: "Test 6: Empty input", + input: make(map[string]string), + expected: make(map[string]interface{}), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup viper with test input + setupViper(tt.input) + + // Call the function + actual := resolveLabels() + + // Assert the results + assert.Equal(t, tt.expected, actual) + }) + } +} + +func TestResolveLabelsWithYAML(t *testing.T) { + tests := []struct { + expected map[string]interface{} + name string + yamlInput string + }{ + { + name: "Test 1: Integer and Float Values", + yamlInput: ` +labels: + key1: "123" + key2: "45.67" +`, + expected: map[string]interface{}{ + "key1": 123, + "key2": 45.67, + }, + }, + { + name: "Test 2: Boolean Values", + yamlInput: ` +labels: + key1: "true" + key2: "false" +`, + expected: map[string]interface{}{ + "key1": true, + "key2": false, + }, + }, + { + name: "Test 3: Nil and Empty Values", + yamlInput: ` +labels: + key1: "nil" + key2: "" +`, + expected: map[string]interface{}{ + "key1": nil, + "key2": nil, + }, + }, + { + name: "Test 4: Array Values", + yamlInput: ` +labels: + key1: "[1, 2, 3]" +`, + expected: map[string]interface{}{ + "key1": []interface{}{float64(1), float64(2), float64(3)}, + }, + }, + { + name: "Test 5: Nested JSON Object", + yamlInput: ` +labels: + key1: '{"a": 1, "b": 2}' +`, + expected: map[string]interface{}{ + "key1": map[string]interface{}{ + "a": float64(1), + "b": float64(2), + }, + }, + }, + { + name: "Test 6: Plain Strings", + yamlInput: ` +labels: + key1: "hello" + key2: "world" +`, + expected: map[string]interface{}{ + "key1": "hello", + "key2": "world", + }, + }, + { + name: "Test 7: Specific Strings Example", + yamlInput: ` +labels: + config-sync-group: "group1" +`, + expected: map[string]interface{}{ + "config-sync-group": "group1", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set up viper with YAML input + viperInstance = viper.New() // Create a new viper instance for isolation + viperInstance.SetConfigType("yaml") + + err := viperInstance.ReadConfig(strings.NewReader(tt.yamlInput)) + require.NoError(t, err, "Error reading YAML input") + + // Call the function + actual := resolveLabels() + + // Assert the results + assert.Equal(t, tt.expected, actual) + }) + } +} + +func TestParseInt(t *testing.T) { + tests := []struct { + expected interface{} + name string + input string + }{ + {name: "Test 1: Valid Integer", input: "123", expected: 123}, + {name: "Test 2: Negative Integer", input: "-456", expected: -456}, + {name: "Test 3: Zero", input: "0", expected: 0}, + {name: "Test 4: Invalid Integer", input: "abc", expected: nil}, + {name: "Test 5: Empty String", input: "", expected: nil}, + {name: "Test 6: Float String", input: "45.67", expected: nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseInt(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestParseFloat(t *testing.T) { + tests := []struct { + expected interface{} + name string + input string + }{ + {name: "Test 1: Valid Float", input: "45.67", expected: 45.67}, + {name: "Test 2: Negative Float", input: "-123.45", expected: -123.45}, + {name: "Test 3: Valid Integer as Float", input: "123", expected: 123.0}, + {name: "Test 4: Invalid Float", input: "abc", expected: nil}, + {name: "Test 5: Empty String", input: "", expected: nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseFloat(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestParseBool(t *testing.T) { + tests := []struct { + expected interface{} + name string + input string + }{ + {name: "Test 1: True (lowercase)", input: "true", expected: true}, + {name: "Test 2: False (lowercase)", input: "false", expected: false}, + {name: "Test 3: True (uppercase)", input: "TRUE", expected: true}, + {name: "Test 4: False (uppercase)", input: "FALSE", expected: false}, + {name: "Test 5: Numeric True", input: "1", expected: true}, + {name: "Test 6: Numeric False", input: "0", expected: false}, + {name: "Test 7: Invalid Boolean", input: "abc", expected: nil}, + {name: "Test 8: Empty String", input: "", expected: nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseBool(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestParseJSON(t *testing.T) { + tests := []struct { + expected interface{} + name string + input string + }{ + { + name: "Test 1: Valid JSON Object", + input: `{"a": 1, "b": "text"}`, + expected: map[string]interface{}{ + "a": float64(1), + "b": "text", + }, + }, + { + name: "Test 2: Valid JSON Array", + input: `[1, 2, 3]`, + expected: []interface{}{float64(1), float64(2), float64(3)}, + }, + { + name: "Test 3: Nested JSON", + input: `{"a": {"b": [1, 2, 3]}}`, + expected: map[string]interface{}{ + "a": map[string]interface{}{"b": []interface{}{float64(1), float64(2), float64(3)}}, + }, + }, + {name: "Test 4: Invalid JSON", input: `{"a": 1,`, expected: nil}, + {name: "Test 5: Empty String", input: "", expected: nil}, + {name: "Test 6: Plain String", input: `"hello"`, expected: "hello"}, + {name: "Test 7: Number as JSON", input: "123", expected: float64(123)}, + {name: "Test 8: Boolean as JSON", input: "true", expected: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseJSON(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + func getAgentConfig() *Config { return &Config{ UUID: "", @@ -399,5 +708,6 @@ func getAgentConfig() *Config { ServerName: "server-name", }, }, + Labels: make(map[string]any), } } diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 526ea914c..e3651a940 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -91,3 +91,7 @@ func DefaultAllowedDirectories() []string { "/var/log/nginx", } } + +func DefaultLabels() map[string]string { + return make(map[string]string) +} diff --git a/internal/config/flags.go b/internal/config/flags.go index 8cfa72b97..7677553c1 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -14,6 +14,7 @@ const ( ConfigPathKey = "path" CommandRootKey = "command" DataPlaneConfigRootKey = "data_plane_config" + LabelsRootKey = "labels" LogLevelRootKey = "log" CollectorRootKey = "collector" VersionKey = "version" diff --git a/internal/config/types.go b/internal/config/types.go index f532dad37..ddcc6802f 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -40,6 +40,7 @@ type ( File *File `yaml:"-" mapstructure:"file"` Common *CommonSettings `yaml:"-"` Watchers *Watchers `yaml:"-"` + Labels map[string]any `yaml:"-" mapstructure:"labels"` Version string `yaml:"-"` Path string `yaml:"-"` UUID string `yaml:"-"` diff --git a/internal/watcher/instance/instance_watcher_service.go b/internal/watcher/instance/instance_watcher_service.go index fb64ecdc3..523ad704a 100644 --- a/internal/watcher/instance/instance_watcher_service.go +++ b/internal/watcher/instance/instance_watcher_service.go @@ -19,7 +19,6 @@ import ( "github.com/nginx/agent/v3/internal/logger" "github.com/nginx/agent/v3/internal/model" "github.com/nginx/agent/v3/internal/watcher/process" - "google.golang.org/protobuf/types/known/structpb" ) const defaultAgentPath = "/run/nginx-agent" @@ -285,6 +284,11 @@ func (iw *InstanceWatcherService) agentInstance(ctx context.Context) *mpi.Instan slog.WarnContext(ctx, "Unable to read process location, defaulting to /var/run/nginx-agent", "error", err) } + labels, convertErr := mpi.ConvertToStructs(iw.agentConfig.Labels) + if convertErr != nil { + slog.WarnContext(ctx, "Unable to convert config to labels structure", "error", convertErr) + } + return &mpi.Instance{ InstanceMeta: &mpi.InstanceMeta{ InstanceId: iw.agentConfig.UUID, @@ -298,7 +302,7 @@ func (iw *InstanceWatcherService) agentInstance(ctx context.Context) *mpi.Instan Command: config.ToCommandProto(iw.agentConfig.Command), Metrics: &mpi.MetricsServer{}, File: &mpi.FileServer{}, - Labels: []*structpb.Struct{}, + Labels: labels, Features: iw.agentConfig.Features, MessageBufferSize: "", },