From 831436f48d9770d3562a621143f33c38266bec92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan-Otto=20Kr=C3=B6pke?= Date: Thu, 8 Oct 2026 12:57:04 +0200 Subject: [PATCH] config: accept collectors.disabled and validate custom collector blocks (#2540) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 5.5 Signed-off-by: Jan-Otto Kröpke --- .../collector/performancecounter/types.go | 7 +- internal/collector/registry/types.go | 9 +- internal/collector/wmi/types.go | 9 +- internal/config/config.go | 22 +- internal/config/config_test.go | 333 ++++++++++++++++-- internal/config/export_test.go | 21 ++ internal/config/flatten.go | 22 +- internal/config/flatten_test.go | 16 +- internal/config/yamlstring/yamlstring.go | 101 ++++++ internal/config/yamlstring/yamlstring_test.go | 75 ++++ 10 files changed, 545 insertions(+), 70 deletions(-) create mode 100644 internal/config/export_test.go create mode 100644 internal/config/yamlstring/yamlstring.go create mode 100644 internal/config/yamlstring/yamlstring_test.go diff --git a/internal/collector/performancecounter/types.go b/internal/collector/performancecounter/types.go index 44d317fa..362ceb6b 100644 --- a/internal/collector/performancecounter/types.go +++ b/internal/collector/performancecounter/types.go @@ -18,6 +18,7 @@ package performancecounter import ( + "github.com/prometheus-community/windows_exporter/internal/config/yamlstring" "github.com/prometheus-community/windows_exporter/internal/pdh" "go.yaml.in/yaml/v3" ) @@ -44,6 +45,8 @@ type Counter struct { // https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/54691ebe11bb9ec32b4e35cd31fcb94a352de134/receiver/windowsperfcountersreceiver/README.md?plain=1#L150 -func (*Config) UnmarshalYAML(*yaml.Node) error { - return nil +// UnmarshalYAML decodes the performancecounter block of the configuration file, +// where the objects are kept as a string. See [yamlstring.DecodeBlock]. +func (c *Config) UnmarshalYAML(node *yaml.Node) error { + return yamlstring.DecodeBlock(node, "objects", &c.Objects) } diff --git a/internal/collector/registry/types.go b/internal/collector/registry/types.go index 39e92602..ae4035b7 100644 --- a/internal/collector/registry/types.go +++ b/internal/collector/registry/types.go @@ -18,6 +18,7 @@ package registry import ( + "github.com/prometheus-community/windows_exporter/internal/config/yamlstring" "github.com/prometheus/client_golang/prometheus" "go.yaml.in/yaml/v3" winregistry "golang.org/x/sys/windows/registry" @@ -46,8 +47,8 @@ type Value struct { metricType prometheus.ValueType } -// UnmarshalYAML is a no-op, so the strict config file validation accepts the -// keys being provided as a string. See the performancecounter collector. -func (*Config) UnmarshalYAML(*yaml.Node) error { - return nil +// UnmarshalYAML decodes the registry block of the configuration file, where +// the keys are kept as a string. See [yamlstring.DecodeBlock]. +func (c *Config) UnmarshalYAML(node *yaml.Node) error { + return yamlstring.DecodeBlock(node, "keys", &c.Keys) } diff --git a/internal/collector/wmi/types.go b/internal/collector/wmi/types.go index f843ff1b..abb62fd1 100644 --- a/internal/collector/wmi/types.go +++ b/internal/collector/wmi/types.go @@ -18,6 +18,7 @@ package wmi import ( + "github.com/prometheus-community/windows_exporter/internal/config/yamlstring" "github.com/prometheus-community/windows_exporter/internal/mi" "github.com/prometheus/client_golang/prometheus" "go.yaml.in/yaml/v3" @@ -55,8 +56,8 @@ type Property struct { metricType prometheus.ValueType } -// UnmarshalYAML is a no-op, so the strict config file validation accepts the -// queries being provided as a string. See the performancecounter collector. -func (*Config) UnmarshalYAML(*yaml.Node) error { - return nil +// UnmarshalYAML decodes the wmi block of the configuration file, where the +// queries are kept as a string. See [yamlstring.DecodeBlock]. +func (c *Config) UnmarshalYAML(node *yaml.Node) error { + return yamlstring.DecodeBlock(node, "queries", &c.Queries) } diff --git a/internal/config/config.go b/internal/config/config.go index 0cb10ce4..0e29ee3e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -36,7 +36,8 @@ type configFile struct { Enabled bool `yaml:"enabled"` } `yaml:"debug"` Collectors struct { - Enabled string `yaml:"enabled"` + Enabled string `yaml:"enabled"` + Disabled string `yaml:"disabled"` } `yaml:"collectors"` Collector collector.Config `yaml:"collector"` Log struct { @@ -69,7 +70,7 @@ type getFlagger interface { // Resolver represents a configuration file resolver for kingpin. type Resolver struct { - flags map[string]string + flags map[string][]string } // Parse parses the command line arguments and configuration files. @@ -118,7 +119,7 @@ func ParseConfigFile(args []string) string { // NewConfigFileResolver returns a Resolver structure. func NewConfigFileResolver(filePath string) (*Resolver, error) { - flags := map[string]string{} + flags := map[string][]string{} file, err := os.Open(filePath) if err != nil { @@ -167,9 +168,18 @@ func NewConfigFileResolver(filePath string) (*Resolver, error) { } func (c *Resolver) setDefault(v getFlagger) { - for name, value := range c.flags { - if f := v.GetFlag(name); f != nil { - f.Default(value) + for name, values := range c.flags { + f := v.GetFlag(name) + if f == nil { + continue + } + + // A list sets a repeatable flag, like web.listen-address, once per element. + // Other flags take the list as a comma-separated value. + if repeatable, ok := f.Model().Value.(interface{ IsCumulative() bool }); ok && repeatable.IsCumulative() { + f.Default(values...) + } else { + f.Default(strings.Join(values, ",")) } } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 589d3aa2..dcb433ac 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -18,18 +18,27 @@ package config_test import ( + "fmt" + "go/ast" + "go/parser" + "go/token" "os" "path/filepath" "reflect" "regexp" "slices" + "strconv" "strings" "testing" "time" "github.com/alecthomas/kingpin/v2" "github.com/prometheus-community/windows_exporter/internal/config" + "github.com/prometheus-community/windows_exporter/internal/log" + logflag "github.com/prometheus-community/windows_exporter/internal/log/flag" "github.com/prometheus-community/windows_exporter/pkg/collector" + webflag "github.com/prometheus/exporter-toolkit/web/kingpinflag" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.yaml.in/yaml/v3" ) @@ -72,6 +81,39 @@ func TestNewConfigFileResolver(t *testing.T) { {name: "unknown key", contents: "unknown: value", errorText: "configuration file validation error"}, {name: "unknown collector option", contents: "collector:\n cpu:\n unknown: true\n", errorText: "configuration file validation error"}, {name: "malformed YAML", contents: "log: [", errorText: "configuration file validation error"}, + {name: "empty value", contents: "log:\n file:\n"}, + {name: "objects as JSON", contents: "collector:\n performancecounter:\n objects: '[{\"name\": \"memory\", \"object\": \"Memory\"}]'\n"}, + {name: "objects without value", contents: "collector:\n performancecounter:\n objects:\n"}, + { + name: "unknown performancecounter option", + contents: "collector:\n performancecounter:\n invalid: |-\n - name: memory\n object: Memory\n", + errorText: "line 3: field invalid not found", + }, + { + name: "unknown performancecounter object field", + contents: "collector:\n performancecounter:\n objects: |-\n - name: memory\n invalid: Memory\n", + errorText: "field invalid not found in type performancecounter.Object", + }, + { + name: "performancecounter objects as list", + contents: "collector:\n performancecounter:\n objects:\n - name: memory\n object: Memory\n", + errorText: "line 4: objects must be a string", + }, + { + name: "unknown registry option", + contents: "collector:\n registry:\n key: |-\n - name: test\n", + errorText: "line 3: field key not found", + }, + { + name: "unknown registry value field", + contents: "collector:\n registry:\n keys: |-\n - name: test\n key: HKLM\\SOFTWARE\n values:\n - name: test\n metrc: test\n", + errorText: "field metrc not found in type registry.Value", + }, + { + name: "unknown wmi option", + contents: "collector:\n wmi:\n query: |-\n - name: test\n", + errorText: "line 3: field query not found", + }, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() @@ -98,20 +140,25 @@ func TestNewConfigFileResolver(t *testing.T) { }) } -// TestNewConfigFileResolverCollectorFlags verifies that every collector flag can -// be set from the configuration file: the nested key derived from the flag name -// must exist in collector.Config, pass the strict validation, and be bound back +// TestNewConfigFileResolverFlags verifies that every flag can be set from the +// configuration file: the nested key derived from the flag name must exist in +// the configuration file schema, pass the strict validation, and be bound back // to the same flag. -func TestNewConfigFileResolverCollectorFlags(t *testing.T) { +func TestNewConfigFileResolverFlags(t *testing.T) { t.Parallel() - app := kingpin.New("test", "test") - collector.NewWithFlags(app) + mainFlags := mainFlagNames(t) - for _, flag := range app.Model().Flags { - // collector.exchange.list prints the available sub-collectors and exits; - // it is not a configuration option. - if !strings.HasPrefix(flag.Name, "collector.") || flag.Name == "collector.exchange.list" { + // kingpin's built-in flags, like --help, are not configuration options. + builtin := kingpin.New("test", "test") + + for _, flag := range newApp(mainFlags).Model().Flags { + switch { + case builtin.GetFlag(flag.Name) != nil, + // selects the configuration file itself + flag.Name == "config.file", + // prints the available sub-collectors and exits + flag.Name == "collector.exchange.list": continue } @@ -120,10 +167,10 @@ func TestNewConfigFileResolverCollectorFlags(t *testing.T) { keys := strings.Split(flag.Name, ".") - field, ok := configFieldType(reflect.TypeFor[collector.Config](), keys[1:]) - require.True(t, ok, "collector.Config has no field with the yaml path %s", flag.Name) + field, block, ok := configFieldType(reflect.TypeFor[config.ConfigFile](), keys) + require.True(t, ok, "the configuration file has no key %s", flag.Name) - value, want := sampleConfigValue(t, field) + value, values := sampleConfigValue(t, field, block) contents := value for _, key := range slices.Backward(keys) { @@ -139,64 +186,272 @@ func TestNewConfigFileResolverCollectorFlags(t *testing.T) { resolver, err := config.NewConfigFileResolver(path) require.NoError(t, err, "config:\n%s", data) - bindApp := kingpin.New("test", "test") - collector.NewWithFlags(bindApp) + app := newApp(mainFlags) + require.NoError(t, resolver.Bind(app, nil)) - require.NoError(t, resolver.Bind(bindApp, nil)) - require.Equal(t, []string{want}, bindApp.GetFlag(flag.Name).Model().Default) + want := values + if repeatable, ok := flag.Value.(interface{ IsCumulative() bool }); !ok || !repeatable.IsCumulative() { + want = []string{strings.Join(values, ",")} + } + + require.Equal(t, want, app.GetFlag(flag.Name).Model().Default, "config:\n%s", data) }) } } -// configFieldType returns the type of the struct field reached by following -// the yaml tags in keys, starting at typ. -func configFieldType(typ reflect.Type, keys []string) (reflect.Type, bool) { - if len(keys) == 0 { - return typ, true +// TestConfigFileKeys verifies the reverse: every key of the configuration file +// schema sets a flag, so no key passes the validation only to be ignored. +func TestConfigFileKeys(t *testing.T) { + t.Parallel() + + app := newApp(mainFlagNames(t)) + + for _, key := range configKeys(reflect.TypeFor[config.ConfigFile](), "") { + assert.NotNil(t, app.GetFlag(key), "the configuration file key %s sets no flag", key) + } +} + +// TestNewConfigFileResolverExamples verifies that the example configuration +// files, and the configuration examples in the documentation of the collectors +// that take their list as a string, load and parse. +func TestNewConfigFileResolverExamples(t *testing.T) { + t.Parallel() + + examples := map[string]string{} + + for _, path := range []string{"../../config.yaml", "../../docs/example_config.yml"} { + data, err := os.ReadFile(path) + require.NoError(t, err) + + examples[path] = string(data) } - if typ.Kind() != reflect.Struct { - return nil, false - } + codeBlock := regexp.MustCompile("(?ms)^```(?:yaml|json)\n(.*?)^```") - for field := range typ.Fields() { - if name, _, _ := strings.Cut(field.Tag.Get("yaml"), ","); name == keys[0] { - return configFieldType(field.Type, keys[1:]) + for _, doc := range []struct{ path, block, key string }{ + {"../../docs/collector.performancecounter.md", "performancecounter", "objects"}, + {"../../docs/collector.registry.md", "registry", "keys"}, + {"../../docs/collector.wmi.md", "wmi", "queries"}, + } { + data, err := os.ReadFile(doc.path) + require.NoError(t, err) + + for i, match := range codeBlock.FindAllStringSubmatch(strings.ReplaceAll(string(data), "\r\n", "\n"), -1) { + example := match[1] + + switch { + case strings.HasPrefix(example, "collector:"): + // a configuration file + case strings.HasPrefix(example, "- "), strings.HasPrefix(example, "["): + // a schema example, which is the value of the flag + example = fmt.Sprintf("collector:\n %s:\n %s: |-\n%s", doc.block, doc.key, indent(example, " ")) + default: + // a fragment or an alerting rule + continue + } + + examples[fmt.Sprintf("%s block %d", doc.path, i+1)] = example } } - return nil, false + mainFlags := mainFlagNames(t) + + for name, contents := range examples { + t.Run(name, func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "config.yaml") + require.NoError(t, os.WriteFile(path, []byte(contents), 0o600)) + + resolver, err := config.NewConfigFileResolver(path) + require.NoError(t, err, "config:\n%s", contents) + + app := newApp(mainFlags) + require.NoError(t, resolver.Bind(app, nil)) + + _, err = app.Parse(nil) + require.NoError(t, err, "config:\n%s", contents) + }) + } +} + +// newApp returns a kingpin application with the flags of windows_exporter: the +// flags declared in cmd/windows_exporter, named by mainFlags, and the flags of +// the packages it uses. +func newApp(mainFlags []string) *kingpin.Application { + app := kingpin.New("test", "test") + + for _, name := range mainFlags { + app.Flag(name, "").String() + } + + logFile := &log.AllowedFile{} + _ = logFile.Set("stdout") + + webflag.AddFlags(app, ":9182") + logflag.AddFlags(app, &log.Config{File: logFile}) + collector.NewWithFlags(app) + + return app +} + +// mainFlagNames returns the names of the flags declared in cmd/windows_exporter. +// It is a main package, so the names are read from its source. +func mainFlagNames(t *testing.T) []string { + t.Helper() + + files, err := filepath.Glob(filepath.Join("..", "..", "cmd", "windows_exporter", "*.go")) + require.NoError(t, err) + + var names []string + + fset := token.NewFileSet() + + for _, path := range files { + if strings.HasSuffix(path, "_test.go") { + continue + } + + file, err := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution) + require.NoError(t, err) + + ast.Inspect(file, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok || len(call.Args) == 0 { + return true + } + + if fun, ok := call.Fun.(*ast.SelectorExpr); !ok || fun.Sel.Name != "Flag" { + return true + } + + lit, ok := call.Args[0].(*ast.BasicLit) + require.True(t, ok && lit.Kind == token.STRING, "%s: the flag name is not a string literal", fset.Position(call.Pos())) + + name, err := strconv.Unquote(lit.Value) + require.NoError(t, err) + + names = append(names, name) + + return true + }) + } + + require.Contains(t, names, "collectors.enabled", "no flags found in cmd/windows_exporter") + + return names +} + +// configFieldType returns the type of the struct field reached by following the +// yaml keys, starting at typ. block reports whether the field belongs to a block +// with its own decoding, which takes the value as a string. +func configFieldType(typ reflect.Type, keys []string) (reflect.Type, bool, bool) { + if len(keys) == 0 { + return typ, false, true + } + + if typ.Kind() != reflect.Struct { + return nil, false, false + } + + for field := range typ.Fields() { + if yamlName(field) == keys[0] { + fieldType, block, ok := configFieldType(field.Type, keys[1:]) + + return fieldType, block || reflect.PointerTo(typ).Implements(reflect.TypeFor[yaml.Unmarshaler]()), ok + } + } + + return nil, false, false +} + +// configKeys returns the dotted keys of the leaves of the configuration file +// schema typ. +func configKeys(typ reflect.Type, prefix string) []string { + var keys []string + + for field := range typ.Fields() { + key := yamlName(field) + if key == "" { + continue + } + + if prefix != "" { + key = prefix + "." + key + } + + if field.Type.Kind() == reflect.Struct { + keys = append(keys, configKeys(field.Type, key)...) + } else { + keys = append(keys, key) + } + } + + return keys +} + +// yamlName returns the key of field in YAML, or "" if it has none. +func yamlName(field reflect.StructField) string { + if !field.IsExported() { + return "" + } + + name, _, _ := strings.Cut(field.Tag.Get("yaml"), ",") + + switch name { + case "-": + return "" + case "": + return strings.ToLower(field.Name) + default: + return name + } } // sampleConfigValue returns a configuration file value that decodes into typ, -// together with the flag value the resolver flattens it to. -func sampleConfigValue(t *testing.T, typ reflect.Type) (any, string) { +// together with the values the resolver flattens it to. A field of a block with +// its own decoding takes the value as a string. +func sampleConfigValue(t *testing.T, typ reflect.Type, block bool) (any, []string) { t.Helper() switch { + case block: + return "[]", []string{"[]"} case typ == reflect.TypeFor[time.Duration](): - return "1m", "1m" + return "1m", []string{"1m"} case typ == reflect.TypeFor[*regexp.Regexp](): - return "test", "test" + return "test", []string{"test"} } switch typ.Kind() { case reflect.Bool: - return true, "true" + return true, []string{"true"} case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - return 1, "1" - case reflect.Slice: - return []string{"test"}, "test" + return 1, []string{"1"} + case reflect.Slice, reflect.Interface: + return []string{"test1", "test2"}, []string{"test1", "test2"} case reflect.String: - return "test", "test" + return "test", []string{"test"} default: t.Fatalf("no sample configuration value for type %s", typ) - return nil, "" + return nil, nil } } +// indent prefixes every non-empty line of s. +func indent(s, prefix string) string { + lines := strings.Split(strings.TrimRight(s, "\n"), "\n") + for i, line := range lines { + if line != "" { + lines[i] = prefix + line + } + } + + return strings.Join(lines, "\n") + "\n" +} + func TestParse(t *testing.T) { t.Parallel() diff --git a/internal/config/export_test.go b/internal/config/export_test.go new file mode 100644 index 00000000..ea4c82ee --- /dev/null +++ b/internal/config/export_test.go @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows + +package config + +// ConfigFile exposes the configuration file schema to the external tests. +type ConfigFile = configFile diff --git a/internal/config/flatten.go b/internal/config/flatten.go index 90858242..de48d4d6 100644 --- a/internal/config/flatten.go +++ b/internal/config/flatten.go @@ -17,10 +17,7 @@ package config -import ( - "fmt" - "strings" -) +import "fmt" // convertMap converts a map with any comparable key type to a map with string keys. func convertMap[K comparable, V any](originalMap map[K]V) map[string]V { @@ -34,17 +31,18 @@ func convertMap[K comparable, V any](originalMap map[K]V) map[string]V { return convertedMap } -// flatten flattens a nested map, joining keys with dots. -// e.g. {"a": {"b":"c"}} => {"a.b":"c"} -func flatten(data map[string]any) map[string]string { - result := make(map[string]string) +// flatten flattens a nested map, joining keys with dots. Each key maps to its +// value, or to the elements of a list. Keys without a value are left out. +// e.g. {"a": {"b":"c", "d":["e","f"]}} => {"a.b":["c"], "a.d":["e","f"]} +func flatten(data map[string]any) map[string][]string { + result := make(map[string][]string) flattenHelper("", data, result) return result } -func flattenHelper(prefix string, data map[string]any, result map[string]string) { +func flattenHelper(prefix string, data map[string]any, result map[string][]string) { for k, v := range data { fullKey := k if prefix != "" { @@ -62,9 +60,11 @@ func flattenHelper(prefix string, data map[string]any, result map[string]string) strSlice[i] = fmt.Sprint(elem) } - result[fullKey] = strings.Join(strSlice, ",") + result[fullKey] = strSlice + case nil: + // A key without a value, like "file:", leaves the flag unchanged. default: - result[fullKey] = fmt.Sprint(val) + result[fullKey] = []string{fmt.Sprint(val)} } } } diff --git a/internal/config/flatten_test.go b/internal/config/flatten_test.go index 41709b81..9ecdb30f 100644 --- a/internal/config/flatten_test.go +++ b/internal/config/flatten_test.go @@ -19,6 +19,7 @@ package config import ( "maps" + "slices" "testing" "go.yaml.in/yaml/v3" @@ -33,6 +34,12 @@ func TestConfigFlattening(t *testing.T) { collectors: enabled: cpu,net,service + collector: + textfile: + directories: [C:\a, C:\b] + service: + include: + log: level: debug`) @@ -43,13 +50,14 @@ func TestConfigFlattening(t *testing.T) { t.Error(err) } - expectedResult := map[string]string{ - "collectors.enabled": "cpu,net,service", - "log.level": "debug", + expectedResult := map[string][]string{ + "collectors.enabled": {"cpu,net,service"}, + "collector.textfile.directories": {`C:\a`, `C:\b`}, + "log.level": {"debug"}, } flattenedValues := flatten(data) - if !maps.Equal(expectedResult, flattenedValues) { + if !maps.EqualFunc(expectedResult, flattenedValues, slices.Equal) { t.Errorf("Flattened values do not match!\nExpected result: %s\nActual result: %s", expectedResult, flattenedValues) } } diff --git a/internal/config/yamlstring/yamlstring.go b/internal/config/yamlstring/yamlstring.go new file mode 100644 index 00000000..4bfb767c --- /dev/null +++ b/internal/config/yamlstring/yamlstring.go @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows + +// Package yamlstring decodes configuration file blocks whose value is a YAML or +// JSON document kept as a string, like the objects of the performancecounter +// collector. +package yamlstring + +import ( + "errors" + "fmt" + "io" + "strings" + + "go.yaml.in/yaml/v3" +) + +// DecodeBlock decodes node, the configuration file block of a collector, into +// target. key must be the only key of the block, and its value a string holding +// a YAML or JSON list, the same value the collector's flag takes. A null value +// leaves target unchanged. +// +// Unknown keys, in the block and in the list, are rejected, like everywhere else +// in the configuration file. The errors are returned as a [yaml.TypeError], so +// the decoder reports them together with the other errors of the file. +func DecodeBlock[T any](node *yaml.Node, key string, target *[]T) error { + if node.Kind != yaml.MappingNode { + return &yaml.TypeError{Errors: []string{ + fmt.Sprintf("line %d: cannot unmarshal %s into a block with the key %s", node.Line, node.ShortTag(), key), + }} + } + + var errs []string + + for i := 0; i+1 < len(node.Content); i += 2 { + name, value := node.Content[i], node.Content[i+1] + + if name.Value != key { + errs = append(errs, fmt.Sprintf("line %d: field %s not found, the only field is %s", name.Line, name.Value, key)) + + continue + } + + errs = append(errs, decodeValue(value, key, target)...) + } + + if len(errs) > 0 { + return &yaml.TypeError{Errors: errs} + } + + return nil +} + +// decodeValue decodes the string value of key into target and returns the +// errors, if any. Line numbers inside the string count from its first line. +func decodeValue[T any](value *yaml.Node, key string, target *[]T) []string { + if value.ShortTag() == "!!null" { + return nil + } + + if value.Kind != yaml.ScalarNode || value.ShortTag() != "!!str" { + return []string{fmt.Sprintf("line %d: %s must be a string, use %s: |- followed by the list", value.Line, key, key)} + } + + decoder := yaml.NewDecoder(strings.NewReader(value.Value)) + decoder.KnownFields(true) + + var list []T + + if err := decoder.Decode(&list); err != nil && !errors.Is(err, io.EOF) { + var typeErr *yaml.TypeError + if !errors.As(err, &typeErr) { + return []string{fmt.Sprintf("line %d: in %s, %s", value.Line, key, err)} + } + + errs := make([]string, len(typeErr.Errors)) + for i, e := range typeErr.Errors { + errs[i] = fmt.Sprintf("line %d: in %s, %s", value.Line, key, e) + } + + return errs + } + + *target = list + + return nil +} diff --git a/internal/config/yamlstring/yamlstring_test.go b/internal/config/yamlstring/yamlstring_test.go new file mode 100644 index 00000000..4c123e4e --- /dev/null +++ b/internal/config/yamlstring/yamlstring_test.go @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows + +package yamlstring_test + +import ( + "testing" + + "github.com/prometheus-community/windows_exporter/internal/config/yamlstring" + "github.com/stretchr/testify/require" + "go.yaml.in/yaml/v3" +) + +type item struct { + Name string `yaml:"name"` +} + +type block struct { + Items []item +} + +func (b *block) UnmarshalYAML(node *yaml.Node) error { + return yamlstring.DecodeBlock(node, "items", &b.Items) +} + +func TestDecodeBlock(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + contents string + want []item + errorText string + }{ + {name: "YAML", contents: "items: |-\n - name: a\n - name: b\n", want: []item{{Name: "a"}, {Name: "b"}}}, + {name: "JSON", contents: `items: '[{"name": "a"}]'`, want: []item{{Name: "a"}}}, + {name: "empty string", contents: `items: ""`}, + {name: "null", contents: "items:\n"}, + {name: "unknown key", contents: "itemz: |-\n - name: a\n", errorText: "line 1: field itemz not found, the only field is items"}, + {name: "unknown field", contents: "items: |-\n - invalid: a\n", errorText: "line 1: in items, line 1: field invalid not found in type yamlstring_test.item"}, + {name: "list", contents: "items:\n - name: a\n", errorText: "line 2: items must be a string"}, + {name: "not a list", contents: "items: |-\n name: a\n", errorText: "cannot unmarshal !!map into []yamlstring_test.item"}, + {name: "not a mapping", contents: "[items]", errorText: "line 1: cannot unmarshal !!seq into a block with the key items"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var got block + + err := yaml.Unmarshal([]byte(tc.contents), &got) + if tc.errorText != "" { + require.ErrorContains(t, err, tc.errorText) + + return + } + + require.NoError(t, err) + require.Equal(t, tc.want, got.Items) + }) + } +}