diff --git a/.chloggen/3350-ta-matchlabels.yaml b/.chloggen/3350-ta-matchlabels.yaml new file mode 100755 index 0000000000..321f3d91e7 --- /dev/null +++ b/.chloggen/3350-ta-matchlabels.yaml @@ -0,0 +1,16 @@ +# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix' +change_type: enhancement + +# The name of the component, or a single word describing the area of concern, (e.g. collector, target allocator, auto-instrumentation, opamp, github action) +component: target allocator + +# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`). +note: "Support camelcase matchLabels and matchExpressions in target allocator config" + +# One or more tracking issues related to the change +issues: [3350] + +# (Optional) One or more lines of additional information to render under the primary note. +# These lines will be padded with 2 spaces and then inserted directly into the document. +# Use pipe (|) for multiline entries. +subtext: diff --git a/cmd/otel-allocator/config/config.go b/cmd/otel-allocator/config/config.go index ee55fe0a32..fcb2d381d2 100644 --- a/cmd/otel-allocator/config/config.go +++ b/cmd/otel-allocator/config/config.go @@ -21,9 +21,11 @@ import ( "fmt" "io/fs" "os" + "reflect" "time" "github.com/go-logr/logr" + "github.com/go-viper/mapstructure/v2" "github.com/prometheus/common/model" promconfig "github.com/prometheus/prometheus/config" _ "github.com/prometheus/prometheus/discovery/install" @@ -80,6 +82,101 @@ type HTTPSServerConfig struct { TLSKeyFilePath string `yaml:"tls_key_file_path,omitempty"` } +// StringToModelDurationHookFunc returns a DecodeHookFuncType +// that converts string to time.Duration, which can be used +// as model.Duration. +func StringToModelDurationHookFunc() mapstructure.DecodeHookFuncType { + return func( + f reflect.Type, + t reflect.Type, + data interface{}, + ) (interface{}, error) { + if f.Kind() != reflect.String { + return data, nil + } + + if t != reflect.TypeOf(model.Duration(5)) { + return data, nil + } + + return time.ParseDuration(data.(string)) + } +} + +// MapToPromConfig returns a DecodeHookFuncType that provides a mechanism +// for decoding promconfig.Config involving its own unmarshal logic. +func MapToPromConfig() mapstructure.DecodeHookFuncType { + return func( + f reflect.Type, + t reflect.Type, + data interface{}, + ) (interface{}, error) { + if f.Kind() != reflect.Map { + return data, nil + } + + if t != reflect.TypeOf(&promconfig.Config{}) { + return data, nil + } + + pConfig := &promconfig.Config{} + + mb, err := yaml.Marshal(data.(map[any]any)) + if err != nil { + return nil, err + } + + err = yaml.Unmarshal(mb, pConfig) + if err != nil { + return nil, err + } + return pConfig, nil + } +} + +// MapToLabelSelector returns a DecodeHookFuncType that +// provides a mechanism for decoding both matchLabels and matchExpressions from camelcase to lowercase +// because we use yaml unmarshaling that supports lowercase field names if no `yaml` tag is defined +// and metav1.LabelSelector uses `json` tags. +// If both the camelcase and lowercase version is present, then the camelcase version takes precedence. +func MapToLabelSelector() mapstructure.DecodeHookFuncType { + return func( + f reflect.Type, + t reflect.Type, + data interface{}, + ) (interface{}, error) { + if f.Kind() != reflect.Map { + return data, nil + } + + if t != reflect.TypeOf(&metav1.LabelSelector{}) { + return data, nil + } + + result := &metav1.LabelSelector{} + fMap := data.(map[any]any) + if matchLabels, ok := fMap["matchLabels"]; ok { + fMap["matchlabels"] = matchLabels + delete(fMap, "matchLabels") + } + if matchExpressions, ok := fMap["matchExpressions"]; ok { + fMap["matchexpressions"] = matchExpressions + delete(fMap, "matchExpressions") + } + + b, err := yaml.Marshal(fMap) + if err != nil { + return nil, err + } + + err = yaml.Unmarshal(b, result) + if err != nil { + return nil, err + } + return result, nil + } +} + func LoadFromFile(file string, target *Config) error { return unmarshal(target, file) } @@ -153,14 +250,41 @@ func LoadFromCLI(target *Config, flagSet *pflag.FlagSet) error { return nil } +// unmarshal decodes the contents of the configFile into the cfg argument, using a +// mapstructure decoder with the following notable behaviors. +// Decodes time.Duration from strings (see StringToModelDurationHookFunc). +// Allows custom unmarshaling for promconfig.Config struct that implements yaml.Unmarshaler (see MapToPromConfig). +// Allows custom unmarshaling for metav1.LabelSelector struct using both camelcase and lowercase field names (see MapToLabelSelector). func unmarshal(cfg *Config, configFile string) error { yamlFile, err := os.ReadFile(configFile) if err != nil { return err } - if err = yaml.Unmarshal(yamlFile, cfg); err != nil { + + m := make(map[string]interface{}) + err = yaml.Unmarshal(yamlFile, &m) + if err != nil { return fmt.Errorf("error unmarshaling YAML: %w", err) } + + dc := mapstructure.DecoderConfig{ + TagName: "yaml", + Result: cfg, + DecodeHook: mapstructure.ComposeDecodeHookFunc( + StringToModelDurationHookFunc(), + MapToPromConfig(), + MapToLabelSelector(), + ), + } + + decoder, err := mapstructure.NewDecoder(&dc) + if err != nil { + return err + } + if err := decoder.Decode(m); err != nil { + return err + } + return nil } diff --git a/cmd/otel-allocator/config/config_test.go b/cmd/otel-allocator/config/config_test.go index c1b721b773..32fc3ad6e6 100644 --- a/cmd/otel-allocator/config/config_test.go +++ b/cmd/otel-allocator/config/config_test.go @@ -194,6 +194,264 @@ func TestLoad(t *testing.T) { }, wantErr: assert.NoError, }, + { + name: "service monitor pod monitor selector with camelcase", + args: args{ + file: "./testdata/pod_service_selector_camelcase_test.yaml", + }, + want: Config{ + AllocationStrategy: DefaultAllocationStrategy, + CollectorSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "app.kubernetes.io/instance": "default.test", + "app.kubernetes.io/managed-by": "opentelemetry-operator", + }, + }, + FilterStrategy: DefaultFilterStrategy, + PrometheusCR: PrometheusCRConfig{ + PodMonitorSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "release": "test", + }, + }, + ServiceMonitorSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "release": "test", + }, + }, + ScrapeInterval: DefaultCRScrapeInterval, + }, + PromConfig: &promconfig.Config{ + GlobalConfig: promconfig.GlobalConfig{ + ScrapeInterval: model.Duration(60 * time.Second), + ScrapeProtocols: defaultScrapeProtocols, + ScrapeTimeout: model.Duration(10 * time.Second), + EvaluationInterval: model.Duration(60 * time.Second), + }, + Runtime: promconfig.DefaultRuntimeConfig, + ScrapeConfigs: []*promconfig.ScrapeConfig{ + { + JobName: "prometheus", + EnableCompression: true, + HonorTimestamps: true, + ScrapeInterval: model.Duration(60 * time.Second), + ScrapeProtocols: defaultScrapeProtocols, + ScrapeTimeout: model.Duration(10 * time.Second), + MetricsPath: "/metrics", + Scheme: "http", + HTTPClientConfig: commonconfig.HTTPClientConfig{ + FollowRedirects: true, + EnableHTTP2: true, + }, + ServiceDiscoveryConfigs: []discovery.Config{ + discovery.StaticConfig{ + { + Targets: []model.LabelSet{ + {model.AddressLabel: "prom.domain:9001"}, + {model.AddressLabel: "prom.domain:9002"}, + {model.AddressLabel: "prom.domain:9003"}, + }, + Labels: model.LabelSet{ + "my": "label", + }, + Source: "0", + }, + }, + }, + }, + }, + }, + }, + wantErr: assert.NoError, + }, + { + name: "service monitor pod monitor selector with matchexpressions", + args: args{ + file: "./testdata/pod_service_selector_expressions_test.yaml", + }, + want: Config{ + AllocationStrategy: DefaultAllocationStrategy, + CollectorSelector: &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + { + Key: "app.kubernetes.io/instance", + Operator: metav1.LabelSelectorOpIn, + Values: []string{ + "default.test", + }, + }, + { + Key: "app.kubernetes.io/managed-by", + Operator: metav1.LabelSelectorOpIn, + Values: []string{ + "opentelemetry-operator", + }, + }, + }, + }, + FilterStrategy: DefaultFilterStrategy, + PrometheusCR: PrometheusCRConfig{ + PodMonitorSelector: &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + { + Key: "release", + Operator: metav1.LabelSelectorOpIn, + Values: []string{ + "test", + }, + }, + }, + }, + ServiceMonitorSelector: &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + { + Key: "release", + Operator: metav1.LabelSelectorOpIn, + Values: []string{ + "test", + }, + }, + }, + }, + ScrapeInterval: DefaultCRScrapeInterval, + }, + PromConfig: &promconfig.Config{ + GlobalConfig: promconfig.GlobalConfig{ + ScrapeInterval: model.Duration(60 * time.Second), + ScrapeProtocols: defaultScrapeProtocols, + ScrapeTimeout: model.Duration(10 * time.Second), + EvaluationInterval: model.Duration(60 * time.Second), + }, + Runtime: promconfig.DefaultRuntimeConfig, + ScrapeConfigs: []*promconfig.ScrapeConfig{ + { + JobName: "prometheus", + EnableCompression: true, + HonorTimestamps: true, + ScrapeInterval: model.Duration(60 * time.Second), + ScrapeProtocols: defaultScrapeProtocols, + ScrapeTimeout: model.Duration(10 * time.Second), + MetricsPath: "/metrics", + Scheme: "http", + HTTPClientConfig: commonconfig.HTTPClientConfig{ + FollowRedirects: true, + EnableHTTP2: true, + }, + ServiceDiscoveryConfigs: []discovery.Config{ + discovery.StaticConfig{ + { + Targets: []model.LabelSet{ + {model.AddressLabel: "prom.domain:9001"}, + {model.AddressLabel: "prom.domain:9002"}, + {model.AddressLabel: "prom.domain:9003"}, + }, + Labels: model.LabelSet{ + "my": "label", + }, + Source: "0", + }, + }, + }, + }, + }, + }, + }, + wantErr: assert.NoError, + }, + { + name: "service monitor pod monitor selector with camelcase matchexpressions", + args: args{ + file: "./testdata/pod_service_selector_camelcase_expressions_test.yaml", + }, + want: Config{ + AllocationStrategy: DefaultAllocationStrategy, + CollectorSelector: &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + { + Key: "app.kubernetes.io/instance", + Operator: metav1.LabelSelectorOpIn, + Values: []string{ + "default.test", + }, + }, + { + Key: "app.kubernetes.io/managed-by", + Operator: metav1.LabelSelectorOpIn, + Values: []string{ + "opentelemetry-operator", + }, + }, + }, + }, + FilterStrategy: DefaultFilterStrategy, + PrometheusCR: PrometheusCRConfig{ + PodMonitorSelector: &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + { + Key: "release", + Operator: metav1.LabelSelectorOpIn, + Values: []string{ + "test", + }, + }, + }, + }, + ServiceMonitorSelector: &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + { + Key: "release", + Operator: metav1.LabelSelectorOpIn, + Values: []string{ + "test", + }, + }, + }, + }, + ScrapeInterval: DefaultCRScrapeInterval, + }, + PromConfig: &promconfig.Config{ + GlobalConfig: promconfig.GlobalConfig{ + ScrapeInterval: model.Duration(60 * time.Second), + ScrapeProtocols: defaultScrapeProtocols, + ScrapeTimeout: model.Duration(10 * time.Second), + EvaluationInterval: model.Duration(60 * time.Second), + }, + Runtime: promconfig.DefaultRuntimeConfig, + ScrapeConfigs: []*promconfig.ScrapeConfig{ + { + JobName: "prometheus", + EnableCompression: true, + HonorTimestamps: true, + ScrapeInterval: model.Duration(60 * time.Second), + ScrapeProtocols: defaultScrapeProtocols, + ScrapeTimeout: model.Duration(10 * time.Second), + MetricsPath: "/metrics", + Scheme: "http", + HTTPClientConfig: commonconfig.HTTPClientConfig{ + FollowRedirects: true, + EnableHTTP2: true, + }, + ServiceDiscoveryConfigs: []discovery.Config{ + discovery.StaticConfig{ + { + Targets: []model.LabelSet{ + {model.AddressLabel: "prom.domain:9001"}, + {model.AddressLabel: "prom.domain:9002"}, + {model.AddressLabel: "prom.domain:9003"}, + }, + Labels: model.LabelSet{ + "my": "label", + }, + Source: "0", + }, + }, + }, + }, + }, + }, + }, + wantErr: assert.NoError, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/cmd/otel-allocator/config/testdata/pod_service_selector_camelcase_expressions_test.yaml b/cmd/otel-allocator/config/testdata/pod_service_selector_camelcase_expressions_test.yaml new file mode 100644 index 0000000000..ecf374650d --- /dev/null +++ b/cmd/otel-allocator/config/testdata/pod_service_selector_camelcase_expressions_test.yaml @@ -0,0 +1,30 @@ +collector_selector: + matchExpressions: + - key: "app.kubernetes.io/instance" + operator: "In" + values: + - "default.test" + - key: "app.kubernetes.io/managed-by" + operator: "In" + values: + - "opentelemetry-operator" +prometheus_cr: + pod_monitor_selector: + matchExpressions: + - key: "release" + operator: "In" + values: + - "test" + service_monitor_selector: + matchExpressions: + - key: "release" + operator: "In" + values: + - "test" +config: + scrape_configs: + - job_name: prometheus + static_configs: + - targets: ["prom.domain:9001", "prom.domain:9002", "prom.domain:9003"] + labels: + my: label \ No newline at end of file diff --git a/cmd/otel-allocator/config/testdata/pod_service_selector_camelcase_test.yaml b/cmd/otel-allocator/config/testdata/pod_service_selector_camelcase_test.yaml new file mode 100644 index 0000000000..b503c619ec --- /dev/null +++ b/cmd/otel-allocator/config/testdata/pod_service_selector_camelcase_test.yaml @@ -0,0 +1,18 @@ +collector_selector: + matchLabels: + app.kubernetes.io/instance: default.test + app.kubernetes.io/managed-by: opentelemetry-operator +prometheus_cr: + pod_monitor_selector: + matchLabels: + release: test + service_monitor_selector: + matchLabels: + release: test +config: + scrape_configs: + - job_name: prometheus + static_configs: + - targets: ["prom.domain:9001", "prom.domain:9002", "prom.domain:9003"] + labels: + my: label \ No newline at end of file diff --git a/cmd/otel-allocator/config/testdata/pod_service_selector_expressions_test.yaml b/cmd/otel-allocator/config/testdata/pod_service_selector_expressions_test.yaml new file mode 100644 index 0000000000..0b2fd44b74 --- /dev/null +++ b/cmd/otel-allocator/config/testdata/pod_service_selector_expressions_test.yaml @@ -0,0 +1,30 @@ +collector_selector: + matchexpressions: + - key: "app.kubernetes.io/instance" + operator: "In" + values: + - "default.test" + - key: "app.kubernetes.io/managed-by" + operator: "In" + values: + - "opentelemetry-operator" +prometheus_cr: + pod_monitor_selector: + matchexpressions: + - key: "release" + operator: "In" + values: + - "test" + service_monitor_selector: + matchexpressions: + - key: "release" + operator: "In" + values: + - "test" +config: + scrape_configs: + - job_name: prometheus + static_configs: + - targets: ["prom.domain:9001", "prom.domain:9002", "prom.domain:9003"] + labels: + my: label \ No newline at end of file diff --git a/go.mod b/go.mod index c787a7ecce..7f343fd5ee 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/gin-gonic/gin v1.10.0 github.com/go-kit/log v0.2.1 github.com/go-logr/logr v1.4.2 + github.com/go-viper/mapstructure/v2 v2.2.1 github.com/google/uuid v1.6.0 github.com/json-iterator/go v1.1.12 github.com/mitchellh/mapstructure v1.5.0 diff --git a/go.sum b/go.sum index 0098fee496..6d10f1d917 100644 --- a/go.sum +++ b/go.sum @@ -236,6 +236,8 @@ github.com/go-resty/resty/v2 v2.13.1/go.mod h1:GznXlLxkq6Nh4sU59rPmUw3VtgpO3aS96 github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= +github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-zookeeper/zk v1.0.3 h1:7M2kwOsc//9VeeFiPtf+uSJlVpU66x9Ba5+8XK7/TDg= github.com/go-zookeeper/zk v1.0.3/go.mod h1:nOB03cncLtlp4t+UAkGSV+9beXP/akpekBwL+UX1Qcw= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=