diff --git a/hack/release.toml b/hack/release.toml index 43bf149964..121551adc1 100644 --- a/hack/release.toml +++ b/hack/release.toml @@ -99,6 +99,13 @@ Talos can be configued to use Kubernetes 1.21 or CAPI v0.4.x components can be u description = """\ Added support for Equinix Metal IPs for the Talos virtual (shared) IP (option `equnixMetal` under `vip` in the machine configuration). Talos automatically re-assigns IP using the Equinix Metal API when leadership changes. +""" + + [notes.configuration] + title = "Machine Config Validation" + description = """\ +Unknown keys in the machine config now make the config invalid, +so any attempt to apply/edit the configuration with the unknown keys will lead into an error. """ [make_deps] diff --git a/pkg/machinery/config/decoder/decoder.go b/pkg/machinery/config/decoder/decoder.go index 81a62ae84b..0726426127 100644 --- a/pkg/machinery/config/decoder/decoder.go +++ b/pkg/machinery/config/decoder/decoder.go @@ -13,6 +13,7 @@ import ( "gopkg.in/yaml.v3" "github.com/talos-systems/talos/pkg/machinery/config" + "github.com/talos-systems/talos/pkg/machinery/config/encoder" ) var ( @@ -95,7 +96,7 @@ func parse(source []byte) (decoded []interface{}, err error) { } } -//nolint:gocyclo +//nolint:gocyclo,cyclop func decode(manifest *yaml.Node) (target interface{}, err error) { var ( version string @@ -136,6 +137,10 @@ func decode(manifest *yaml.Node) (target interface{}, err error) { return nil, fmt.Errorf("deprecated decode: %w", err) } + if err = validate(target, manifest); err != nil { + return nil, err + } + return target, nil } } @@ -164,5 +169,96 @@ func decode(manifest *yaml.Node) (target interface{}, err error) { return nil, fmt.Errorf("spec decode: %w", err) } + if err = validate(target, spec); err != nil { + return nil, err + } + return target, nil } + +//nolint:gocyclo +func validate(target interface{}, spec *yaml.Node) error { + node, err := encoder.NewEncoder(target).Marshal() + if err != nil { + return err + } + + src := map[string]interface{}{} + dst := map[string]interface{}{} + + err = spec.Decode(src) + if err != nil { + return err + } + + err = node.Decode(dst) + if err != nil { + return err + } + + var checkUnknown func(interface{}, interface{}) interface{} + + checkUnknown = func(left interface{}, right interface{}) interface{} { + switch v := left.(type) { + case map[string]interface{}: + r, ok := right.(map[string]interface{}) + if !ok { + return "type mismatch" + } + + unknownKeys := map[string]interface{}{} + + for key, value := range v { + if _, ok := r[key]; !ok { + unknownKeys[key] = value + + continue + } + + if d := checkUnknown(value, r[key]); d != nil { + unknownKeys[key] = d + } + } + + if len(unknownKeys) > 0 { + return unknownKeys + } + case []interface{}: + r, ok := right.([]interface{}) + if !ok { + return "type mismatch" + } + + if len(v) != len(r) { + return "slice length differs" + } + + var unknownItems []interface{} + + for i, item := range v { + if d := checkUnknown(item, r[i]); d != nil { + unknownItems = append(unknownItems, d) + } + } + + if len(unknownItems) > 0 { + return unknownItems + } + } + + return nil + } + + diff := checkUnknown(src, dst) + if diff != nil { + var data []byte + + if data, err = yaml.Marshal(diff); err != nil { + return fmt.Errorf("failed to marshal error summary %w", err) + } + + return fmt.Errorf("unknown keys found during decoding:\n%s", string(data)) + } + + return nil +} diff --git a/pkg/machinery/config/decoder/decoder_test.go b/pkg/machinery/config/decoder/decoder_test.go index e8e8b141e0..07cd5f8161 100644 --- a/pkg/machinery/config/decoder/decoder_test.go +++ b/pkg/machinery/config/decoder/decoder_test.go @@ -17,8 +17,17 @@ type Mock struct { Test bool `yaml:"test"` } +type MockV2 struct { + Slice []Mock `yaml:"slice"` + Map map[string]Mock `yaml:"map"` +} + func init() { - config.Register("mock", func(string) interface{} { + config.Register("mock", func(version string) interface{} { + if version == "v1alpha2" { + return &MockV2{} + } + return &Mock{} }) } @@ -137,6 +146,85 @@ spec: want: nil, wantErr: true, }, + { + name: "extra field", + fields: fields{ + source: []byte(`--- +kind: mock +version: v1alpha1 +spec: + test: true + extra: fail +`), + }, + want: nil, + wantErr: true, + }, + { + name: "extra fields in map", + fields: fields{ + source: []byte(`--- +kind: mock +version: v1alpha2 +spec: + map: + first: + test: true + extra: me +`), + }, + want: nil, + wantErr: true, + }, + { + name: "extra fields in slice", + fields: fields{ + source: []byte(`--- +kind: mock +version: v1alpha2 +spec: + slice: + - test: true + not: working + more: extra + fields: here +`), + }, + want: nil, + wantErr: true, + }, + { + name: "valid nested", + fields: fields{ + source: []byte(`--- +kind: mock +version: v1alpha2 +spec: + slice: + - test: true + map: + first: + test: true + second: + test: false +`), + }, + want: []interface{}{ + &MockV2{ + Map: map[string]Mock{ + "first": { + Test: true, + }, + "second": { + Test: false, + }, + }, + Slice: []Mock{ + {Test: true}, + }, + }, + }, + }, } for _, tt := range tests {