mirror of
https://github.com/prometheus-community/windows_exporter.git
synced 2026-10-17 01:59:04 +02:00
config: accept collectors.disabled and validate custom collector blocks (#2540)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Jan-Otto Kröpke <mail@jkroepke.de>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
3a7bac6fe8
commit
831436f48d
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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, ","))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+294
-39
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
+11
-11
@@ -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)}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user