check / check (push) Successful in 9m9s
config.Parse walks the file as written and fails on the first key that
matches no setting, naming its dotted path and line, so a typo in
exclude or prune_grace_period no longer falls back silently. Keys that a
${...} reference brings in are checked once smartconfig has filled it
in, and named without a line. YAML merge keys and a top-level env
section, which smartconfig reads, still load; an anchor that contains
itself is an error, and each anchored map is checked once per type.
config set refuses such a key before writing, with the same error, and
sets a key under env like any other value. The key-to-field lookup
moves from internal/cli to config.SettingType so both use it.
Model: opus-5-5
215 lines
5.4 KiB
Go
215 lines
5.4 KiB
Go
package config
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// yamlMergeTag is the tag YAML gives a merge key (<<), which adds the keys
|
|
// of the map it names to the map it sits in.
|
|
const yamlMergeTag = "!!merge"
|
|
|
|
// EnvSection is the top-level key of a map of environment variables that
|
|
// smartconfig sets while it loads the config. It is not a vaultik setting,
|
|
// so its keys are not checked.
|
|
const EnvSection = "env"
|
|
|
|
var (
|
|
errUnknownKey = errors.New("unknown config key")
|
|
errAnchorContainsItself = errors.New("anchor contains itself")
|
|
)
|
|
|
|
// nodeAndType is a node of a parsed config and the setting type it is
|
|
// checked against.
|
|
type nodeAndType struct {
|
|
node *yaml.Node
|
|
typ reflect.Type
|
|
}
|
|
|
|
// SettingType returns the type of the setting that the dotted key names in
|
|
// Config, following the fields' yaml tags, as for s3.bucket or
|
|
// snapshots.home.exclude.0. A snapshot name or a list index may be any
|
|
// word. It returns an unknown key error when no setting has that key.
|
|
func SettingType(keys []string) (reflect.Type, error) {
|
|
typ := reflect.TypeFor[Config]()
|
|
|
|
for _, key := range keys {
|
|
switch {
|
|
case typ.Kind() == reflect.Map || typ.Kind() == reflect.Slice:
|
|
typ = typ.Elem()
|
|
case typ.Kind() == reflect.Struct:
|
|
field, ok := yamlField(typ, key)
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w %q", errUnknownKey, strings.Join(keys, "."))
|
|
}
|
|
|
|
typ = field.Type
|
|
default:
|
|
return nil, fmt.Errorf("%w %q", errUnknownKey, strings.Join(keys, "."))
|
|
}
|
|
}
|
|
|
|
return typ, nil
|
|
}
|
|
|
|
// yamlField returns the field of struct type typ whose yaml tag names key.
|
|
func yamlField(typ reflect.Type, key string) (reflect.StructField, bool) {
|
|
for field := range typ.Fields() {
|
|
name, _, _ := strings.Cut(field.Tag.Get("yaml"), ",")
|
|
if name == key && name != "-" {
|
|
return field, true
|
|
}
|
|
}
|
|
|
|
return reflect.StructField{}, false
|
|
}
|
|
|
|
// checkKeys returns an error naming the first key in node, a parsed config
|
|
// or part of one at the dotted path, that no setting in typ has. The error
|
|
// gives the key's line in the file, when it has one. A value of the wrong
|
|
// type is left for the YAML decoder to report. following holds the aliases
|
|
// the walk is inside. Meeting one of them again means its anchor contains
|
|
// itself, which is an error. checked holds each anchored node the walk has
|
|
// checked completely, with the type it was checked against. An alias to
|
|
// that node is not followed again for that type.
|
|
func checkKeys(node *yaml.Node, typ reflect.Type, path string,
|
|
following map[*yaml.Node]bool, checked map[nodeAndType]bool,
|
|
) error {
|
|
switch node.Kind {
|
|
case yaml.DocumentNode:
|
|
for _, child := range node.Content {
|
|
err := checkKeys(child, typ, path, following, checked)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
case yaml.AliasNode:
|
|
if following[node] {
|
|
return fmt.Errorf("%w: %q on line %d",
|
|
errAnchorContainsItself, node.Value, node.Line)
|
|
}
|
|
|
|
target := nodeAndType{node.Alias, typ}
|
|
if checked[target] {
|
|
return nil
|
|
}
|
|
|
|
following[node] = true
|
|
defer delete(following, node)
|
|
|
|
err := checkKeys(node.Alias, typ, path, following, checked)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
checked[target] = true
|
|
case yaml.SequenceNode:
|
|
if typ.Kind() != reflect.Slice {
|
|
return nil
|
|
}
|
|
|
|
for i, child := range node.Content {
|
|
err := checkKeys(child, typ.Elem(), joinKey(path, strconv.Itoa(i)),
|
|
following, checked)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
case yaml.MappingNode:
|
|
return checkMappingKeys(node, typ, path, following, checked)
|
|
case yaml.ScalarNode:
|
|
// A scalar has no keys.
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// checkFilledKeys is checkKeys for data, the config after smartconfig has
|
|
// filled in its ${...} references. A key that a reference brings in is not
|
|
// in the file, and the copy that Encode makes has no line numbers.
|
|
func checkFilledKeys(data map[string]any) error {
|
|
var filled yaml.Node
|
|
|
|
err := filled.Encode(data)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal config data: %w", err)
|
|
}
|
|
|
|
return checkKeys(&filled, reflect.TypeFor[Config](), "",
|
|
map[*yaml.Node]bool{}, map[nodeAndType]bool{})
|
|
}
|
|
|
|
// checkMappingKeys is checkKeys for a map.
|
|
func checkMappingKeys(node *yaml.Node, typ reflect.Type, path string,
|
|
following map[*yaml.Node]bool, checked map[nodeAndType]bool,
|
|
) error {
|
|
for i := 0; i+1 < len(node.Content); i += 2 {
|
|
key, value := node.Content[i], node.Content[i+1]
|
|
|
|
if key.Tag == yamlMergeTag {
|
|
// The value is one map or a list of maps.
|
|
merged := []*yaml.Node{value}
|
|
if value.Kind == yaml.SequenceNode {
|
|
merged = value.Content
|
|
}
|
|
|
|
for _, m := range merged {
|
|
err := checkKeys(m, typ, path, following, checked)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
continue
|
|
}
|
|
|
|
keyPath := joinKey(path, key.Value)
|
|
if keyPath == EnvSection {
|
|
continue
|
|
}
|
|
|
|
var valueType reflect.Type
|
|
|
|
switch {
|
|
case typ.Kind() == reflect.Map:
|
|
valueType = typ.Elem()
|
|
case typ.Kind() == reflect.Struct:
|
|
field, ok := yamlField(typ, key.Value)
|
|
if !ok && key.Line == 0 {
|
|
// A key that a ${...} reference brought in has no line.
|
|
return fmt.Errorf("%w %q", errUnknownKey, keyPath)
|
|
}
|
|
|
|
if !ok {
|
|
return fmt.Errorf("%w %q on line %d",
|
|
errUnknownKey, keyPath, key.Line)
|
|
}
|
|
|
|
valueType = field.Type
|
|
default:
|
|
return nil
|
|
}
|
|
|
|
err := checkKeys(value, valueType, keyPath, following, checked)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// joinKey appends key to the dotted path.
|
|
func joinKey(path, key string) string {
|
|
if path == "" {
|
|
return key
|
|
}
|
|
|
|
return path + "." + key
|
|
}
|