collector: derive the collector maps from one registration table (#2730)

Signed-off-by: Jan-Otto Kröpke <mail@jkroepke.de>
This commit is contained in:
Jan-Otto Kröpke
2026-10-15 21:19:47 +00:00
committed by GitHub
parent b914dbbf6e
commit 379b1553bd
10 changed files with 805 additions and 231 deletions
+40 -23
View File
@@ -21,7 +21,10 @@ import (
"errors"
"fmt"
"io"
"maps"
"os"
"reflect"
"slices"
"strings"
"github.com/alecthomas/kingpin/v2"
@@ -30,7 +33,9 @@ import (
)
// configFile represents the structure of the windows_exporter configuration file,
// including configuration from the collector and web packages.
// including configuration from the collector and web packages. Each leaf is a
// flag; the field types document the value of the flag, which parses and
// validates it. [validateKeys] checks the keys of the file against this schema.
type configFile struct {
Debug struct {
Enabled bool `yaml:"enabled"`
@@ -45,17 +50,17 @@ type configFile struct {
Format string `yaml:"format"`
File string `yaml:"file"`
Rotation struct {
MaxSize string `yaml:"max-size"`
MaxBackups string `yaml:"max-backups"`
Compress bool `yaml:"compress"`
MaxSize int `yaml:"max-size"`
MaxBackups int `yaml:"max-backups"`
Compress bool `yaml:"compress"`
} `yaml:"rotation"`
} `yaml:"log"`
Process struct {
Priority string `yaml:"priority"`
MemoryLimit string `yaml:"memory-limit"`
MemoryLimit int64 `yaml:"memory-limit"`
} `yaml:"process"`
Scrape struct {
TimeoutMargin string `yaml:"timeout-margin"`
TimeoutMargin float64 `yaml:"timeout-margin"`
} `yaml:"scrape"`
Telemetry struct {
Path string `yaml:"path"`
@@ -76,6 +81,8 @@ type getFlagger interface {
// Resolver represents a configuration file resolver for kingpin.
type Resolver struct {
flags map[string][]string
// lines maps the keys of the configuration file to their lines.
lines map[string]int
}
// Parse parses the command line arguments and configuration files.
@@ -135,12 +142,9 @@ func NewConfigFileResolver(filePath string) (*Resolver, error) {
_ = file.Close()
}()
var configFileStructure configFile
var document yaml.Node
decoder := yaml.NewDecoder(file)
decoder.KnownFields(true)
if err = decoder.Decode(&configFileStructure); err != nil {
if err = yaml.NewDecoder(file).Decode(&document); err != nil {
// Handle EOF error gracefully, indicating no configuration was found.
if errors.Is(err, io.EOF) {
return &Resolver{flags: flags}, nil
@@ -149,18 +153,18 @@ func NewConfigFileResolver(filePath string) (*Resolver, error) {
return nil, fmt.Errorf("configuration file validation error: %w", err)
}
_, err = file.Seek(0, io.SeekStart)
if err != nil {
return nil, fmt.Errorf("failed to rewind file: %w", err)
}
// Decoding first rejects duplicate keys and expands aliases within the
// limits of the decoder before the schema walks the document.
var rawValues map[string]any
decoder = yaml.NewDecoder(file)
if err = decoder.Decode(&rawValues); err != nil {
if err = document.Decode(&rawValues); err != nil {
return nil, fmt.Errorf("failed to parse configuration file: %w", err)
}
if err = validateKeys(&document, "", reflect.TypeFor[configFile]()); err != nil {
return nil, fmt.Errorf("configuration file validation error: %w", err)
}
// Flatten nested YAML values
flattenedValues := flatten(rawValues)
for k, v := range flattenedValues {
@@ -169,16 +173,25 @@ func NewConfigFileResolver(filePath string) (*Resolver, error) {
}
}
return &Resolver{flags: flags}, nil
lines := map[string]int{}
keyLines(&document, "", lines)
return &Resolver{flags: flags, lines: lines}, nil
}
func (c *Resolver) setDefault(v getFlagger) {
for name, values := range c.flags {
func (c *Resolver) setDefault(v getFlagger) error {
for _, name := range slices.Sorted(maps.Keys(c.flags)) {
values := c.flags[name]
f := v.GetFlag(name)
if f == nil {
continue
}
if err := checkFlagValue(f, values); err != nil {
return fmt.Errorf("line %d: %s: %w", c.lines[name], name, err)
}
// 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() {
@@ -187,6 +200,8 @@ func (c *Resolver) setDefault(v getFlagger) {
f.Default(strings.Join(values, ","))
}
}
return nil
}
// Bind sets active flags with their default values from the configuration file(s).
@@ -197,10 +212,12 @@ func (c *Resolver) Bind(app *kingpin.Application, args []string) error {
return err
}
c.setDefault(app)
if err = c.setDefault(app); err != nil {
return err
}
if pc.SelectedCommand != nil {
c.setDefault(pc.SelectedCommand)
return c.setDefault(pc.SelectedCommand)
}
return nil
+94
View File
@@ -109,6 +109,21 @@ func TestNewConfigFileResolver(t *testing.T) {
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: "quoted bool", contents: "log:\n rotation:\n compress: \"true\"\n"},
{name: "quoted number", contents: "log:\n rotation:\n max-size: \"10\"\n"},
{name: "list as string", contents: "collector:\n mscluster:\n enabled: cluster,node\n"},
{name: "string as list", contents: "collectors:\n enabled: [cpu, memory]\n"},
{name: "duration as string", contents: "collector:\n hyperv:\n virtual-hard-disk:\n refresh-interval: 30s\n"},
{name: "renamed collector block", contents: "collector:\n pagefile:\n smbclient:\n"},
{name: "old collector block", contents: "collector:\n paging:\n", errorText: "line 2: field paging not found in collector"},
{name: "unknown nested key", contents: "collector:\n cpu:\n unknown: true\n", errorText: "line 3: field unknown not found in collector.cpu"},
{name: "unknown top-level key", contents: "unknown: value\n", errorText: "line 1: field unknown not found in the configuration"},
{name: "mapping as value", contents: "log:\n level:\n debug: true\n", errorText: "line 3: log.level must be a value or a list of values"},
{name: "mapping in list", contents: "web:\n listen-address:\n - port: 9182\n", errorText: "line 3: web.listen-address must be a value or a list of values"},
{name: "value as section", contents: "log: debug\n", errorText: "line 1: log must be a mapping"},
{name: "merge key", contents: "collector:\n service: &filter\n include: a\n process:\n <<: *filter\n exclude: b\n"},
{name: "merged section", contents: "log: &log\n level: debug\nweb:\n <<: *log\n", errorText: "line 2: field level not found in web"},
{name: "duplicate key", contents: "log:\n level: debug\n level: info\n", errorText: "already defined"},
{
name: "unknown wmi option",
contents: "collector:\n wmi:\n query: |-\n - name: test\n",
@@ -174,6 +189,11 @@ func TestNewConfigFileResolverFlags(t *testing.T) {
value, values := sampleConfigValue(t, field, block)
// These flags only accept some strings.
if sample, ok := map[string]string{"log.level": "debug", "log.format": "json"}[flag.Name]; ok {
value, values = sample, []string{sample}
}
contents := value
for _, key := range slices.Backward(keys) {
contents = map[string]any{key: contents}
@@ -431,6 +451,8 @@ func sampleConfigValue(t *testing.T, typ reflect.Type, block bool) (any, []strin
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return 1, []string{"1"}
case reflect.Float32, reflect.Float64:
return 1.5, []string{"1.5"}
case reflect.Slice, reflect.Interface:
return []string{"test1", "test2"}, []string{"test1", "test2"}
case reflect.String:
@@ -525,3 +547,75 @@ func TestParseEmptyValue(t *testing.T) {
})
}
}
// TestParseFlagValues verifies that the configuration file takes the values the
// flags accept, whatever the Go type of the configuration, and that an invalid
// value fails with its key and line, even if the command line sets the flag.
func TestParseFlagValues(t *testing.T) {
t.Parallel()
mainFlags := mainFlagNames(t)
for _, tc := range []struct {
name string
config string
args []string
flag string
want string
errorText string
}{
{name: "quoted bool", config: "log:\n rotation:\n compress: \"true\"\n", flag: "log.rotation.compress", want: "true"},
{name: "quoted int", config: "log:\n rotation:\n max-size: \"10\"\n", flag: "log.rotation.max-size", want: "10"},
{
name: "comma-separated list", config: "collector:\n mscluster:\n enabled: cluster,node\n",
flag: "collector.mscluster.enabled", want: "cluster,node",
},
{
name: "list of strings", config: "collector:\n mscluster:\n enabled: [cluster, node]\n",
flag: "collector.mscluster.enabled", want: "cluster,node",
},
{
name: "repeatable flag", config: "web:\n listen-address: ['127.0.0.1:9182', '[::1]:9182']\n",
flag: "web.listen-address", want: "127.0.0.1:9182,[::1]:9182",
},
{
name: "merge key", config: "collector:\n service: &filter\n include: a\n process:\n <<: *filter\n exclude: b\n",
flag: "collector.process.include", want: "a",
},
{
name: "CLI overrides config", config: "log:\n rotation:\n compress: true\n",
args: []string{"--no-log.rotation.compress"}, flag: "log.rotation.compress", want: "false",
},
{
name: "invalid bool", config: "log:\n rotation:\n compress: maybe\n",
errorText: "line 3: log.rotation.compress: strconv.ParseBool",
},
{
name: "invalid int", config: "debug:\n enabled: true\nlog:\n rotation:\n max-size: ten\n",
errorText: "line 5: log.rotation.max-size: strconv.Parse",
},
{
name: "invalid value set on the CLI", config: "log:\n rotation:\n compress: maybe\n",
args: []string{"--log.rotation.compress"}, errorText: "line 3: log.rotation.compress: strconv.ParseBool",
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(tc.config), 0o600))
app := newApp(mainFlags)
err := config.Parse(app, append([]string{"--config.file=" + path}, tc.args...))
if tc.errorText != "" {
require.ErrorContains(t, err, tc.errorText)
return
}
require.NoError(t, err)
require.Equal(t, tc.want, app.GetFlag(tc.flag).Model().Value.String())
})
}
}
+264
View File
@@ -0,0 +1,264 @@
// 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
import (
"fmt"
"reflect"
"slices"
"strings"
"github.com/alecthomas/kingpin/v2"
"go.yaml.in/yaml/v3"
)
// validateKeys checks that node, the value of the dotted key path, only uses
// the keys of the schema typ.
//
// The configuration file sets flags, and the flags parse and validate the
// values (see [checkFlagValue]). So the schema only defines the keys: a leaf
// takes any scalar or a list of scalars, whatever the type of its field.
// Decoding the values into the field types would reject values that the flags
// accept, like compress: "true" or a comma-separated list for a list field.
//
// A struct that decodes itself, like the performancecounter block, is decoded
// to validate its contents.
func validateKeys(node *yaml.Node, path string, typ reflect.Type) error {
switch node.Kind {
case yaml.DocumentNode:
for _, content := range node.Content {
if err := validateKeys(content, path, typ); err != nil {
return err
}
}
return nil
case yaml.AliasNode:
return validateKeys(node.Alias, path, typ)
case yaml.ScalarNode:
if node.Tag == "!!null" {
// A key without a value leaves the flags unchanged.
return nil
}
case yaml.MappingNode, yaml.SequenceNode:
}
if reflect.PointerTo(typ).Implements(reflect.TypeFor[yaml.Unmarshaler]()) {
//nolint:wrapcheck // the decoder errors name the line and field.
return node.Decode(reflect.New(typ).Interface())
}
if typ.Kind() != reflect.Struct {
if !isValue(node) {
return fmt.Errorf("line %d: %s must be a value or a list of values", node.Line, displayPath(path))
}
return nil
}
if node.Kind != yaml.MappingNode {
return fmt.Errorf("line %d: %s must be a mapping", node.Line, displayPath(path))
}
for i := 0; i+1 < len(node.Content); i += 2 {
key, value := node.Content[i], node.Content[i+1]
if isMerge(key) {
// A merge key (<<) adds the keys of other mappings.
for _, source := range mergeSources(value) {
if err := validateKeys(source, path, typ); err != nil {
return err
}
}
continue
}
field, ok := schemaField(typ, key.Value)
if !ok {
return fmt.Errorf("line %d: field %s not found in %s", key.Line, key.Value, displayPath(path))
}
if err := validateKeys(value, joinPath(path, key.Value), field.Type); err != nil {
return err
}
}
return nil
}
// isValue reports whether node is the value of a flag: a scalar, or a list of
// scalars for a repeatable or list flag.
func isValue(node *yaml.Node) bool {
switch node.Kind {
case yaml.ScalarNode:
return true
case yaml.AliasNode:
return isValue(node.Alias)
case yaml.SequenceNode:
for _, item := range node.Content {
if item.Kind == yaml.AliasNode {
item = item.Alias
}
if item.Kind != yaml.ScalarNode {
return false
}
}
return true
case yaml.DocumentNode, yaml.MappingNode:
}
return false
}
// keyLines records the line of every dotted key path below node in lines.
// Explicit keys take precedence over the keys of merged mappings, like in the
// decoded values.
func keyLines(node *yaml.Node, path string, lines map[string]int) {
switch node.Kind {
case yaml.DocumentNode:
for _, content := range node.Content {
keyLines(content, path, lines)
}
case yaml.AliasNode:
keyLines(node.Alias, path, lines)
case yaml.MappingNode:
for i := 0; i+1 < len(node.Content); i += 2 {
if isMerge(node.Content[i]) {
for _, source := range mergeSources(node.Content[i+1]) {
keyLines(source, path, lines)
}
}
}
for i := 0; i+1 < len(node.Content); i += 2 {
key, value := node.Content[i], node.Content[i+1]
if isMerge(key) {
continue
}
keyPath := joinPath(path, key.Value)
lines[keyPath] = key.Line
keyLines(value, keyPath, lines)
}
case yaml.ScalarNode, yaml.SequenceNode:
}
}
func isMerge(key *yaml.Node) bool {
return key.Kind == yaml.ScalarNode && key.Tag == "!!merge"
}
// mergeSources returns the mappings that a merge key merges: an alias, a
// mapping or a list of them.
func mergeSources(value *yaml.Node) []*yaml.Node {
if value.Kind == yaml.SequenceNode {
return value.Content
}
return []*yaml.Node{value}
}
func joinPath(path, key string) string {
if path == "" {
return key
}
return path + "." + key
}
func displayPath(path string) string {
if path == "" {
return "the configuration"
}
return path
}
// schemaField returns the field of the struct typ with the YAML key name,
// looking into the fields inlined with the yaml inline option.
func schemaField(typ reflect.Type, name string) (reflect.StructField, bool) {
for field := range typ.Fields() {
if !field.IsExported() {
continue
}
key, options, _ := strings.Cut(field.Tag.Get("yaml"), ",")
if slices.Contains(strings.Split(options, ","), "inline") && field.Type.Kind() == reflect.Struct {
if inlined, ok := schemaField(field.Type, name); ok {
return inlined, true
}
continue
}
if key == "" {
key = strings.ToLower(field.Name)
}
if key != "-" && key == name {
return field, true
}
}
return reflect.StructField{}, false
}
// checkFlagValue checks that the flag accepts the values from the
// configuration file.
//
// The flags only parse their default values in kingpin.Application.Parse,
// which does not tell the configuration key, and does not parse them at all
// if the flag is also set on the command line. kingpin cannot copy a flag
// value, so the check sets the flag value itself. That is safe: Parse sets it
// again, to the same default or to the command-line value. A repeatable flag
// would append the values twice, so its list is restored after the check.
func checkFlagValue(flag *kingpin.FlagClause, values []string) error {
value := flag.Model().Value
if repeatable, ok := value.(interface{ IsCumulative() bool }); !ok || !repeatable.IsCumulative() {
//nolint:wrapcheck // the caller adds the key and line.
return value.Set(strings.Join(values, ","))
}
getter, ok := value.(kingpin.Getter)
if !ok {
return nil
}
list := reflect.ValueOf(getter.Get())
if list.Kind() != reflect.Pointer || list.Elem().Kind() != reflect.Slice {
// The list cannot be restored; Parse checks the values.
return nil
}
saved := reflect.ValueOf(list.Elem().Interface())
defer list.Elem().Set(saved)
for _, v := range values {
if err := value.Set(v); err != nil {
//nolint:wrapcheck // the caller adds the key and line.
return err
}
}
return nil
}
+69
View File
@@ -0,0 +1,69 @@
// 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
import (
"reflect"
"testing"
"github.com/stretchr/testify/require"
"go.yaml.in/yaml/v3"
)
// TestValidateKeysInline verifies that the keys of a field inlined with the
// yaml inline option belong to the enclosing mapping.
func TestValidateKeysInline(t *testing.T) {
t.Parallel()
type Inner struct {
Level string `yaml:"level"`
}
type schema struct {
Log struct {
Inner `yaml:",inline"`
Format string `yaml:"format"`
} `yaml:"log"`
}
for _, tc := range []struct {
name string
contents string
errorText string
}{
{name: "inlined key", contents: "log:\n level: debug\n format: json\n"},
{name: "unknown key", contents: "log:\n inner: debug\n", errorText: "line 2: field inner not found in log"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var document yaml.Node
require.NoError(t, yaml.Unmarshal([]byte(tc.contents), &document))
err := validateKeys(&document, "", reflect.TypeFor[schema]())
if tc.errorText != "" {
require.EqualError(t, err, tc.errorText)
return
}
require.NoError(t, err)
})
}
}