diff --git a/hack/release.toml b/hack/release.toml index 4525023fa0..490b8a9ade 100644 --- a/hack/release.toml +++ b/hack/release.toml @@ -241,6 +241,13 @@ Extra announced endpoints can be added using the [`KubespanEndpointsConfig` docu title = "Machine Configuration via Kernel Command Line" description = """\ Talos Linux supports supplying zstd-compressed, base64-encoded machine configuration small documents via the kernel command line parameter `talos.config.inline`. +""" + + [notes.patch-delete] + title = "Removing parts of the configuration using `$patch: delete` syntax" + description = """\ +Talos Linux now supports removing parts of the configuration using the `$patch: delete` syntax similar to the kubernetes. +More information can be found [here](https://www.talos.dev/v1.8/talos-guides/configuration/patching/#strategic-merge-patches). """ [make_deps] diff --git a/pkg/machinery/config/configloader/configloader.go b/pkg/machinery/config/configloader/configloader.go index 9abd785989..d658a68661 100644 --- a/pkg/machinery/config/configloader/configloader.go +++ b/pkg/machinery/config/configloader/configloader.go @@ -21,7 +21,13 @@ import ( var ErrNoConfig = errors.New("config not found") // newConfig initializes and returns a Configurator. -func newConfig(r io.Reader) (config config.Provider, err error) { +func newConfig(r io.Reader, opt ...Opt) (config config.Provider, err error) { + var opts Opts + + for _, o := range opt { + o(&opts) + } + dec := decoder.NewDecoder() var buf bytes.Buffer @@ -29,7 +35,7 @@ func newConfig(r io.Reader) (config config.Provider, err error) { // preserve the original contents r = io.TeeReader(r, &buf) - manifests, err := dec.Decode(r) + manifests, err := dec.Decode(r, opts.allowPatchDelete) if err != nil { return nil, err } @@ -59,6 +65,30 @@ func NewFromStdin() (config.Provider, error) { } // NewFromBytes will take a byteslice and attempt to parse a config file from it. -func NewFromBytes(source []byte) (config.Provider, error) { - return newConfig(bytes.NewReader(source)) +func NewFromBytes(source []byte, o ...Opt) (config.Provider, error) { + return newConfig(bytes.NewReader(source), o...) +} + +// Opts represents the options for the config loader. +type Opts struct { + allowPatchDelete bool +} + +// Opt is a functional option for the config loader. +type Opt func(*Opts) + +// WithAllowPatchDelete allows the loader to parse patch delete operations. +func WithAllowPatchDelete() Opt { + return func(o *Opts) { + o.allowPatchDelete = true + } } + +// Selector represents a delete selector for a document. +type Selector = decoder.Selector + +// ErrZeroedDocument is returned when the document is empty after applying the delete selector. +var ErrZeroedDocument = decoder.ErrZeroedDocument + +// ErrLookupFailed is returned when the lookup failed. +var ErrLookupFailed = decoder.ErrLookupFailed diff --git a/pkg/machinery/config/configloader/internal/decoder/decoder.go b/pkg/machinery/config/configloader/internal/decoder/decoder.go index 662bb0b829..5007ea95db 100644 --- a/pkg/machinery/config/configloader/internal/decoder/decoder.go +++ b/pkg/machinery/config/configloader/internal/decoder/decoder.go @@ -39,8 +39,8 @@ const ( type Decoder struct{} // Decode decodes all known manifests. -func (d *Decoder) Decode(r io.Reader) ([]config.Document, error) { - return parse(r) +func (d *Decoder) Decode(r io.Reader, allowPatchDelete bool) ([]config.Document, error) { + return parse(r, allowPatchDelete) } // NewDecoder initializes and returns a `Decoder`. @@ -54,7 +54,8 @@ type documentID struct { Name string } -func parse(r io.Reader) (decoded []config.Document, err error) { +//nolint:gocyclo +func parse(r io.Reader, allowPatchDelete bool) (decoded []config.Document, err error) { // Recover from yaml.v3 panics because we rely on machine configuration loading _a lot_. defer func() { if p := recover(); p != nil { @@ -71,7 +72,7 @@ func parse(r io.Reader) (decoded []config.Document, err error) { knownDocuments := map[documentID]struct{}{} // Iterate through all defined documents. - for { + for i := 0; ; i++ { var manifests yaml.Node if err = dec.Decode(&manifests); err != nil { @@ -86,6 +87,17 @@ func parse(r io.Reader) (decoded []config.Document, err error) { return nil, errors.New("expected a document") } + if allowPatchDelete { + decoded, err = AppendDeletesTo(&manifests, decoded, i) + if err != nil { + return nil, err + } + + if manifests.IsZero() { + continue + } + } + for _, manifest := range manifests.Content { id := documentID{ APIVersion: findValue(manifest, ManifestAPIVersionKey, false), @@ -167,24 +179,3 @@ func decode(manifest *yaml.Node) (target config.Document, err error) { return target, nil } - -func findValue(node *yaml.Node, key string, required bool) string { - if node.Kind != yaml.MappingNode { - panic(errors.New("expected a mapping node")) - } - - for i := 0; i < len(node.Content)-1; i += 2 { - keyNode := node.Content[i] - val := node.Content[i+1] - - if keyNode.Kind == yaml.ScalarNode && keyNode.Value == key { - return val.Value - } - } - - if required { - panic(fmt.Errorf("missing '%s'", key)) - } - - return "" -} diff --git a/pkg/machinery/config/configloader/internal/decoder/decoder_test.go b/pkg/machinery/config/configloader/internal/decoder/decoder_test.go index f01885cb99..5907dac966 100644 --- a/pkg/machinery/config/configloader/internal/decoder/decoder_test.go +++ b/pkg/machinery/config/configloader/internal/decoder/decoder_test.go @@ -311,7 +311,7 @@ config: t.Parallel() d := decoder.NewDecoder() - actual, err := d.Decode(bytes.NewReader(tt.source)) + actual, err := d.Decode(bytes.NewReader(tt.source), false) if tt.expected != nil { assert.Equal(t, tt.expected, actual) @@ -340,7 +340,7 @@ func TestDecoderV1Alpha1Config(t *testing.T) { require.NoError(t, err) d := decoder.NewDecoder() - _, err = d.Decode(bytes.NewReader(contents)) + _, err = d.Decode(bytes.NewReader(contents), false) assert.NoError(t, err) }) @@ -354,7 +354,7 @@ func TestDoubleV1Alpha1(t *testing.T) { contents := must.Value(files.ReadFile("v1alpha1.yaml"))(t) d := decoder.NewDecoder() - _, err := d.Decode(bytes.NewReader(contents)) + _, err := d.Decode(bytes.NewReader(contents), false) require.Error(t, err) require.ErrorContains(t, err, "not allowed") } @@ -367,7 +367,7 @@ func BenchmarkDecoderV1Alpha1Config(b *testing.B) { for range b.N { d := decoder.NewDecoder() - _, err = d.Decode(bytes.NewReader(contents)) + _, err = d.Decode(bytes.NewReader(contents), false) assert.NoError(b, err) } diff --git a/pkg/machinery/config/configloader/internal/decoder/delete.go b/pkg/machinery/config/configloader/internal/decoder/delete.go new file mode 100644 index 0000000000..a7f437eae9 --- /dev/null +++ b/pkg/machinery/config/configloader/internal/decoder/delete.go @@ -0,0 +1,234 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package decoder + +import ( + "errors" + "fmt" + "slices" + "strconv" + "strings" + + "gopkg.in/yaml.v3" + + "github.com/siderolabs/talos/pkg/machinery/config/config" + "github.com/siderolabs/talos/pkg/machinery/config/types/v1alpha1" +) + +// AppendDeletesTo appends all delete selectors found in the given YAML node to the given destination slice. +func AppendDeletesTo(n *yaml.Node, dest []config.Document, idx int) (_ []config.Document, err error) { + defer func() { + if r := recover(); r != nil { + if re, ok := r.(error); ok { + err = re + } + } + }() + + allDeletes(n)(func(path []string, elem delElem) bool { + switch elem.parent.Kind { + case yaml.DocumentNode: + dest = append(dest, makeSelector(path, n.Content[0], idx, "", "")) + case yaml.MappingNode: + dest = append(dest, makeSelector(path, n.Content[0], idx, "", "")) + case yaml.SequenceNode: + dest = append(dest, makeSequenceSelector(path, n.Content[0], elem.node, idx)) + case yaml.ScalarNode, yaml.AliasNode: + } + + return true + }) + + return dest, nil +} + +func allDeletes(node *yaml.Node) func(yield func([]string, delElem) bool) { + return func(yield func([]string, delElem) bool) { + _, okToDel := processNode(nil, node, make([]string, 0, 8), yield) + if okToDel { + *node = yaml.Node{} + } + } +} + +func makeSequenceSelector(path []string, root, node *yaml.Node, i int) Selector { + if node.Kind != yaml.MappingNode { + panic(errors.New("expected a mapping node")) + } + + // map node inside sequence node, collect the first key:val aside from $patch:delete as selector + for j := 0; j < len(node.Content)-1; j += 2 { + key := node.Content[j] + val := node.Content[j+1] + + if val.Kind == yaml.ScalarNode && key.Value == "$patch" && val.Value == "delete" { + continue + } + + return makeSelector(path, root, i, key.Value, val.Value) + } + + panic(errors.New("no key:val found in sequence node for path " + strings.Join(path, "."))) +} + +func makeSelector(path []string, root *yaml.Node, i int, key, val string) Selector { + isRequired := len(path) == 0 + + apiVersion := findValue(root, "apiVersion", isRequired) + kind := findValue(root, "kind", isRequired) + + switch { + case kind == "" && apiVersion == "": + kind = v1alpha1.Version // legacy document + case kind != "" && apiVersion != "": + default: + panic(fmt.Errorf("kind and apiVersion must be both set for path %s", strings.Join(path, "."))) + } + + sel := selector{ + path: slices.Clone(path), + docIdx: i, + docAPIVersion: apiVersion, + docKind: kind, + key: key, + value: val, + } + + switch name := findValue(root, "name", false); name { + case "": + return &sel + default: + return &namedSelector{ + selector: sel, + name: name, + } + } +} + +type delElem struct { + path []string + parent, node *yaml.Node +} + +// processNode recursively processes a YAML node, searching for a "$patch: delete" nodes and calling the yield function +// with path for each one found. +// +//nolint:gocyclo,cyclop +func processNode( + parent, v *yaml.Node, + path []string, + yield func(path []string, d delElem) bool, +) (bool, bool) { + if v.Kind != yaml.DocumentNode && parent == nil { + panic(errors.New("parent must be non-nil for non-document nodes")) + } + + switch v.Kind { + case yaml.DocumentNode: + okToCont, okToDel := processNode(v, v.Content[0], path, yield) + + switch { + case !okToCont: + return false, okToDel + case okToDel: + return false, true + default: + return false, isEmptyDoc(v.Content[0]) + } + + case yaml.MappingNode: + for i := 0; i < len(v.Content)-1; i += 2 { + keyNode := v.Content[i] + valueNode := v.Content[i+1] + + if valueNode.Kind == yaml.ScalarNode && keyNode.Value == "$patch" && valueNode.Value == "delete" { + if parent.Kind != yaml.SequenceNode { + ensureNoSeqInChain(path) + } + + return yield(path, delElem{path: path, parent: parent, node: v}), true + } + + okToCont, okToDel := processNode(v, valueNode, append(path, keyNode.Value), yield) + if !okToCont { + return false, okToDel + } else if okToDel { + v.Content = slices.Delete(v.Content, i, i+2) + i -= 2 + + if len(v.Content) == 0 { + return true, true + } + } + } + case yaml.SequenceNode: + for i := 0; i < len(v.Content); i++ { + okToCont, okToDel := processNode(v, v.Content[i], append(path, "["+strconv.Itoa(i)+"]"), yield) + if !okToCont { + return false, okToDel + } else if okToDel { + v.Content = slices.Delete(v.Content, i, i+1) + i-- + + if len(v.Content) == 0 { + return true, true + } + } + } + case yaml.ScalarNode, yaml.AliasNode: + } + + return true, false +} + +func isEmptyDoc(node *yaml.Node) bool { + if node.Kind != yaml.MappingNode { + return false + } + + for i := 0; i < len(node.Content)-1; i += 2 { + keyNode := node.Content[i] + val := node.Content[i+1] + + if keyNode.Kind != yaml.ScalarNode || val.Kind != yaml.ScalarNode { + return false + } + + if keyNode.Value != "version" && keyNode.Value != "kind" && keyNode.Value != "name" { + return false + } + } + + return true +} + +func ensureNoSeqInChain(path []string) { + for _, p := range path { + if p[0] == '[' { + panic(errors.New("cannot delete an inner key in '" + strings.Join(path, ".") + "'")) + } + } +} + +func findValue(node *yaml.Node, key string, required bool) string { + if node.Kind != yaml.MappingNode { + panic(errors.New("expected a mapping node")) + } + + for i := 0; i < len(node.Content)-1; i += 2 { + keyNode := node.Content[i] + val := node.Content[i+1] + + if keyNode.Kind == yaml.ScalarNode && keyNode.Value == key { + return val.Value + } + } + + if required { + panic(fmt.Errorf("missing %s in document for which $patch: delete is used", key)) + } + + return "" +} diff --git a/pkg/machinery/config/configloader/internal/decoder/delete_test.go b/pkg/machinery/config/configloader/internal/decoder/delete_test.go new file mode 100644 index 0000000000..69be0edffa --- /dev/null +++ b/pkg/machinery/config/configloader/internal/decoder/delete_test.go @@ -0,0 +1,98 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package decoder_test + +import ( + "bytes" + _ "embed" + "fmt" + "io" + "strings" + "testing" + + "github.com/siderolabs/gen/xslices" + "github.com/siderolabs/gen/xtesting/must" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" + + "github.com/siderolabs/talos/pkg/machinery/config/config" + "github.com/siderolabs/talos/pkg/machinery/config/configloader/internal/decoder" + "github.com/siderolabs/talos/pkg/machinery/config/encoder" +) + +var ( + //go:embed testdata/delete/delete.yaml + patchDelete []byte + //go:embed testdata/delete/delete_expected.yaml + patchDeleteExpected []byte +) + +func TestExtractDeletes(t *testing.T) { + result, b := must.Values(extractDeletes(patchDelete))(t) + + defer func() { + if !t.Failed() { + return + } + + for _, sel := range result { + t.Logf("%#v", sel) + } + }() + + require.Equal(t, string(patchDeleteExpected), string(b)) + + expected := strings.Join( + []string{ + "{apiVersion:v1alpha1, kind:SideroLinkConfig, idx:0}", + "{path:configFiles.[0], apiVersion:v1alpha1, kind:ExtensionServiceConfig, key:content, value:hello, idx:1, name:foo}", + "{path:machine.hostname, kind:v1alpha1, idx:2}", + "{path:machine.network.[0], kind:v1alpha1, key:interface, value:eth0, idx:2}", + }, + "\n", + ) + + actual := strings.Join( + xslices.Map(result, func(sel config.Document) string { return sel.(fmt.Stringer).String() }), + "\n", + ) + + require.Equal(t, expected, actual) +} + +func extractDeletes(in []byte) (result []config.Document, _ []byte, err error) { + var cleanedBytes [][]byte + + dec := yaml.NewDecoder(bytes.NewReader(in)) + + for i := 0; ; i++ { + node := &yaml.Node{} + + err = dec.Decode(node) + if err != nil { + if err == io.EOF { + break + } + + return nil, nil, err + } + + result, err = decoder.AppendDeletesTo(node, result, i) + if err != nil { + return nil, nil, err + } + + if !node.IsZero() { + b, err := encoder.NewEncoder(node, encoder.WithComments(encoder.CommentsDisabled)).Encode() + if err != nil { + return nil, nil, err + } + + cleanedBytes = append(cleanedBytes, b) + } + } + + return result, bytes.Join(cleanedBytes, []byte("---\n")), nil +} diff --git a/pkg/machinery/config/configloader/internal/decoder/selector.go b/pkg/machinery/config/configloader/internal/decoder/selector.go new file mode 100644 index 0000000000..752521740a --- /dev/null +++ b/pkg/machinery/config/configloader/internal/decoder/selector.go @@ -0,0 +1,292 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package decoder + +import ( + "errors" + "fmt" + "reflect" + "slices" + "strconv" + "strings" + + "github.com/siderolabs/go-pointer" + + "github.com/siderolabs/talos/pkg/machinery/config/config" +) + +// Selector represents a delete selector for a document. +type Selector interface { + config.Document + DocIdx() int + ApplyTo(config.Document) error +} + +type selector struct { + path []string + docIdx int + docAPIVersion string + docKind string + key string + value string +} + +func (s *selector) Kind() string { return s.docKind } +func (s *selector) APIVersion() string { return s.docAPIVersion } +func (s *selector) Clone() config.Document { return pointer.To(s.clone()) } +func (s *selector) DocIdx() int { return s.docIdx } + +func (s *selector) PathAsString() string { return strings.Join(s.path, ".") } + +func (s *selector) clone() selector { + return selector{ + path: slices.Clone(s.path), + docIdx: s.docIdx, + docAPIVersion: s.docAPIVersion, + docKind: s.docKind, + key: s.key, + value: s.value, + } +} + +func (s *selector) String() string { return s.toString("") } + +func (s *selector) toString(more string) string { + var builder strings.Builder + + writeThing := func(key, val string) { + if val != "" { + if builder.Len() > 1 { + builder.WriteString(", ") + } + + builder.WriteString(key) + builder.WriteRune(':') + builder.WriteString(val) + } + } + + builder.WriteRune('{') + writeThing("path", s.PathAsString()) + writeThing("apiVersion", s.docAPIVersion) + writeThing("kind", s.docKind) + writeThing("key", s.key) + writeThing("value", s.value) + writeThing("idx", strconv.Itoa(s.docIdx)) + + if more != "" { + builder.WriteString(", ") + builder.WriteString(more) + } + + builder.WriteRune('}') + + return builder.String() +} + +// ErrZeroedDocument is returned when the document is empty after applying the delete selector. +var ErrZeroedDocument = errors.New("document is empty now") + +// ApplyTo applies the delete selector to the given document. +func (s *selector) ApplyTo(doc config.Document) error { + if err := s.applyTo(doc); err != nil { + return fmt.Errorf("patch delete: path '%s' in document '%s/%s': %w", s.PathAsString(), doc.APIVersion(), doc.Kind(), err) + } + + return nil +} + +func (s *selector) applyTo(doc config.Document) error { + if s.docKind != doc.Kind() || s.docAPIVersion != doc.APIVersion() { + return fmt.Errorf( + "incorrect document type for %s/%s", + s.docAPIVersion, + s.docKind, + ) + } + + val := reflect.ValueOf(doc) + + if val.Kind() != reflect.Pointer { + return fmt.Errorf("document type is not a pointer") + } + + if len(s.path) == 0 { + if doc.Kind() == "" { + return errors.New("can't delete the root of the legacy document") + } + + return ErrZeroedDocument + } + + err := deleteForPath(val.Elem(), s.path, s.key, s.value) + if err != nil { + return fmt.Errorf("failed to delete path '%s': %w", s.PathAsString(), err) + } + + return nil +} + +var searchForType = reflect.TypeFor[string]() + +// ErrLookupFailed is returned when the lookup failed. +var ErrLookupFailed = errors.New("lookup failed") + +//nolint:gocyclo +func deleteForPath(val reflect.Value, path []string, key, value string) error { + if len(path) == 0 { + return errors.New("path is empty") + } + + if val.Kind() == reflect.Pointer || val.Kind() == reflect.Interface { + return deleteForPath(val.Elem(), path, key, value) + } + + searchFor := path[0] + path = path[1:] + valType := val.Type() + + switch val.Kind() { //nolint:exhaustive + case reflect.Struct: + // Lookup using yaml tag + for i := range val.NumField() { + structField := valType.Field(i) + + yamlTagRaw, ok := structField.Tag.Lookup("yaml") + if !ok { + continue + } + + yamlTags := strings.Split(yamlTagRaw, ",") + if yamlTags[0] == searchFor { + if len(path) == 0 { + val.Field(i).SetZero() + + return nil + } + + return deleteForPath(val.Field(i), path, key, value) + } + } + case reflect.Map: + if val.IsNil() { + break + } + + keyType := valType.Key() + + // Try assingable and convertible types for key search + if searchForType.AssignableTo(keyType) || searchForType.ConvertibleTo(keyType) { + searchForVal := reflect.ValueOf(searchFor) + + if searchForType != keyType { + searchForVal = searchForVal.Convert(keyType) + } + + if idx := val.MapIndex(searchForVal); idx.IsValid() { + if len(path) == 0 { + val.SetMapIndex(searchForVal, reflect.Zero(valType.Elem())) + + return nil + } + + return deleteForPath(idx, path, key, value) + } + } + case reflect.Slice: + return deleteStructFrom(val, searchFor, path, key, value) + } + + return ErrLookupFailed +} + +//nolint:gocyclo +func deleteStructFrom(searchIn reflect.Value, searchFor string, path []string, key, value string) error { + switch { + case len(path) != 0: + return errors.New("searching for complex paths in slices is not supported") + case searchFor == "": + return errors.New("searching for '' in a slice is not supported") + case searchFor[0] != '[': + return errors.New("searching for non-integer keys in slices is not supported") + case searchIn.Kind() != reflect.Slice: + return errors.New("searching for a key in a non-slice") + } + + for i := 0; i < searchIn.Len(); i++ { //nolint:intrange + elem := searchIn.Index(i) + + for elem.Kind() == reflect.Pointer { + elem = elem.Elem() + } + + if elem.Kind() != reflect.Struct && elem.Kind() != reflect.Map { + continue + } + + elemType := elem.Type() + + if elem.Kind() == reflect.Struct { + for j := range elemType.NumField() { + structField := elemType.Field(j) + + yamlTagRaw, ok := structField.Tag.Lookup("yaml") + if !ok { + continue + } + + yamlTags := strings.Split(yamlTagRaw, ",") + if yamlTags[0] != key { + continue + } + + if elem.Field(j).String() != value { + continue + } + + searchIn.Set(reflect.AppendSlice(searchIn.Slice(0, i), searchIn.Slice(i+1, searchIn.Len()))) + + return nil + } + } else { + continue + } + } + + return ErrLookupFailed +} + +type namedSelector struct { + selector + name string +} + +func (n *namedSelector) Name() string { return n.name } +func (n *namedSelector) String() string { return n.toString("name:" + n.name) } +func (n *namedSelector) Clone() config.Document { + return &namedSelector{selector: n.selector.clone(), name: n.name} +} + +// ApplyTo applies the delete selector to the given document. +func (n *namedSelector) ApplyTo(doc config.Document) error { + if err := n.applyTo(doc); err != nil { + return fmt.Errorf("named patch delete: document %s/%s: %w", doc.APIVersion(), doc.Kind(), err) + } + + return nil +} + +func (n *namedSelector) applyTo(doc config.Document) error { + namedDoc, ok := doc.(config.NamedDocument) + if !ok { + return errors.New("not a named document, expected " + n.name) + } + + if n.name != namedDoc.Name() { + return fmt.Errorf("name mismatch, expected %s, got %s", n.name, namedDoc.Name()) + } + + return n.selector.applyTo(doc) +} diff --git a/pkg/machinery/config/configloader/internal/decoder/testdata/delete/delete.yaml b/pkg/machinery/config/configloader/internal/decoder/testdata/delete/delete.yaml new file mode 100644 index 0000000000..b11c6ec083 --- /dev/null +++ b/pkg/machinery/config/configloader/internal/decoder/testdata/delete/delete.yaml @@ -0,0 +1,24 @@ +apiVersion: v1alpha1 +kind: SideroLinkConfig +$patch: delete +--- +apiVersion: v1alpha1 +kind: ExtensionServiceConfig +name: foo +configFiles: +- content: hello + $patch: delete +- content: hello2 + mountPath: /etc/foo2 +--- +version: v1alpha1 +machine: + hostname: + $patch: delete + network: + - interface: eth0 + $patch: delete + - interface: eth1 + addresses: [10.3.5.5/32] + - interface: eth0 + dhcp6: true diff --git a/pkg/machinery/config/configloader/internal/decoder/testdata/delete/delete_expected.yaml b/pkg/machinery/config/configloader/internal/decoder/testdata/delete/delete_expected.yaml new file mode 100644 index 0000000000..fe33ae6370 --- /dev/null +++ b/pkg/machinery/config/configloader/internal/decoder/testdata/delete/delete_expected.yaml @@ -0,0 +1,14 @@ +apiVersion: v1alpha1 +kind: ExtensionServiceConfig +name: foo +configFiles: + - content: hello2 + mountPath: /etc/foo2 +--- +version: v1alpha1 +machine: + network: + - interface: eth1 + addresses: [10.3.5.5/32] + - interface: eth0 + dhcp6: true diff --git a/pkg/machinery/config/configpatcher/apply_test.go b/pkg/machinery/config/configpatcher/apply_test.go index 2875970c37..08aebec965 100644 --- a/pkg/machinery/config/configpatcher/apply_test.go +++ b/pkg/machinery/config/configpatcher/apply_test.go @@ -210,3 +210,85 @@ func TestApplyWithManifestNewline(t *testing.T) { }) } } + +//go:embed testdata/patchdelete/config.yaml +var configMultidocDelete []byte + +//go:embed testdata/patchdelete/expected.yaml +var expectedMultidocDelete string + +func TestApplyMultiDocDelete(t *testing.T) { + patches, err := configpatcher.LoadPatches([]string{ + "@testdata/patchdelete/strategic1.yaml", + }) + require.NoError(t, err) + + cfg, err := configloader.NewFromBytes(configMultidocDelete) + require.NoError(t, err) + + for _, tt := range []struct { + name string + input configpatcher.Input + }{ + { + name: "WithConfig", + input: configpatcher.WithConfig(cfg), + }, + { + name: "WithBytes", + input: configpatcher.WithBytes(configMultidocDelete), + }, + } { + t.Run(tt.name, func(t *testing.T) { + out, err := configpatcher.Apply(tt.input, patches) + require.NoError(t, err) + + bytes, err := out.Bytes() + require.NoError(t, err) + + assert.Equal(t, expectedMultidocDelete, string(bytes)) + }) + } +} + +//go:embed testdata/patchdelete/controlplane_orig.yaml +var controlPlane []byte + +//go:embed testdata/patchdelete/controlplane_expected.yaml +var controlPlaneExpected string + +func TestApplyMultiDocCPDelete(t *testing.T) { + patches, err := configpatcher.LoadPatches([]string{ + "@testdata/patchdelete/strategic2.yaml", + "@testdata/patchdelete/strategic3.yaml", + "@testdata/patchdelete/strategic4.yaml", + }) + require.NoError(t, err) + + cfg, err := configloader.NewFromBytes(controlPlane) + require.NoError(t, err) + + for _, tt := range []struct { + name string + input configpatcher.Input + }{ + { + name: "WithConfig", + input: configpatcher.WithConfig(cfg), + }, + { + name: "WithBytes", + input: configpatcher.WithBytes(controlPlane), + }, + } { + t.Run(tt.name, func(t *testing.T) { + out, err := configpatcher.Apply(tt.input, patches) + require.NoError(t, err) + + bytes, err := out.Bytes() + require.NoError(t, err) + + assert.Equal(t, controlPlaneExpected, string(bytes)) + }) + } +} diff --git a/pkg/machinery/config/configpatcher/load.go b/pkg/machinery/config/configpatcher/load.go index 486de89ef2..4e63818dcd 100644 --- a/pkg/machinery/config/configpatcher/load.go +++ b/pkg/machinery/config/configpatcher/load.go @@ -21,7 +21,7 @@ type patch []map[string]any // LoadPatch loads the strategic merge patch or JSON patch (JSON/YAML for JSON patch). func LoadPatch(in []byte) (Patch, error) { // Try configloader first, as it is more strict about the config format - cfg, strategicErr := configloader.NewFromBytes(in) + cfg, strategicErr := configloader.NewFromBytes(in, configloader.WithAllowPatchDelete()) if strategicErr == nil { return NewStrategicMergePatch(cfg), nil } diff --git a/pkg/machinery/config/configpatcher/strategic.go b/pkg/machinery/config/configpatcher/strategic.go index 9ceb9c7622..ff69e2906a 100644 --- a/pkg/machinery/config/configpatcher/strategic.go +++ b/pkg/machinery/config/configpatcher/strategic.go @@ -5,10 +5,14 @@ package configpatcher import ( + "errors" + "slices" + "github.com/siderolabs/gen/xslices" coreconfig "github.com/siderolabs/talos/pkg/machinery/config" "github.com/siderolabs/talos/pkg/machinery/config/config" + "github.com/siderolabs/talos/pkg/machinery/config/configloader" "github.com/siderolabs/talos/pkg/machinery/config/container" "github.com/siderolabs/talos/pkg/machinery/config/merge" ) @@ -46,8 +50,25 @@ func StrategicMerge(cfg coreconfig.Provider, patch StrategicMergePatch) (corecon id := documentID(rightDoc) if leftDoc, ok := leftIndex[id]; ok { - if err := merge.Merge(leftDoc, rightDoc); err != nil { - return nil, err + sel, isSel := rightDoc.(configloader.Selector) + if !isSel { + if err := merge.Merge(leftDoc, rightDoc); err != nil { + return nil, err + } + + continue + } + + err := sel.ApplyTo(leftDoc) + if err != nil { + if !errors.Is(err, configloader.ErrZeroedDocument) { + return nil, err + } + + delete(leftIndex, id) + + idx := slices.Index(left, leftDoc) + left = slices.Delete(left, idx, idx+1) } } else { left = append(left, rightDoc) diff --git a/pkg/machinery/config/configpatcher/testdata/patchdelete/config.yaml b/pkg/machinery/config/configpatcher/testdata/patchdelete/config.yaml new file mode 100644 index 0000000000..31cb025e05 --- /dev/null +++ b/pkg/machinery/config/configpatcher/testdata/patchdelete/config.yaml @@ -0,0 +1,22 @@ +version: v1alpha1 +machine: + network: + hostname: hostname1 + interfaces: + - interface: eth0 + dhcp: true + - interface: eth1 + addresses: [10.3.5.4/32] +--- +apiVersion: v1alpha1 +kind: SideroLinkConfig +apiUrl: https://siderolink.api/join?jointoken=secret&user=alice +--- +apiVersion: v1alpha1 +kind: ExtensionServiceConfig +name: foo +configFiles: + - content: hello + mountPath: /etc/foo +environment: + - FOO=BAR diff --git a/pkg/machinery/config/configpatcher/testdata/patchdelete/controlplane_expected.yaml b/pkg/machinery/config/configpatcher/testdata/patchdelete/controlplane_expected.yaml new file mode 100644 index 0000000000..87d6722672 --- /dev/null +++ b/pkg/machinery/config/configpatcher/testdata/patchdelete/controlplane_expected.yaml @@ -0,0 +1,73 @@ +version: v1alpha1 +debug: false +persist: true +machine: + type: controlplane + token: d8cwfa.eyvpi0xwxyarbfid + ca: + crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJQakNCOGFBREFnRUNBaEI5cStGVXpodzkycHVPemtpNzB1eGRNQVVHQXl0bGNEQVFNUTR3REFZRFZRUUsKRXdWMFlXeHZjekFlRncweU16RXdNVEl4TURRMk1EbGFGdzB6TXpFd01Ea3hNRFEyTURsYU1CQXhEakFNQmdOVgpCQW9UQlhSaGJHOXpNQ293QlFZREsyVndBeUVBaHVLczZxeCtKWi8wWG8ybXdpQUNjK1EwSVYySGhMd3ozVTZICmUxemZjS2lqWVRCZk1BNEdBMVVkRHdFQi93UUVBd0lDaERBZEJnTlZIU1VFRmpBVUJnZ3JCZ0VGQlFjREFRWUkKS3dZQkJRVUhBd0l3RHdZRFZSMFRBUUgvQkFVd0F3RUIvekFkQmdOVkhRNEVGZ1FVSlgzWlVNRktWWFZ5NWhKWQozZG9NWENpVEJZRXdCUVlESzJWd0EwRUFCbUxrbDhITmQ3cUpEN3VqQkk2UG9abVRQQWlEcU9GQ0NTVDZJYlZDClF3UzQ1bk1tMldtalRIc3ZrYU5FQ0dneTBhQXJaaFdsbnVYWUswY0t3Z2VJQ0E9PQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg== + key: LS0tLS1CRUdJTiBFRDI1NTE5IFBSSVZBVEUgS0VZLS0tLS0KTUM0Q0FRQXdCUVlESzJWd0JDSUVJTURXbklEdVpSdlhQcW1tbSt6bk15SWMrdk53ZjdnYksvSmR3WC9iN2d1RQotLS0tLUVORCBFRDI1NTE5IFBSSVZBVEUgS0VZLS0tLS0K + certSANs: [] + kubelet: + image: ghcr.io/siderolabs/kubelet:v1.28.0 + defaultRuntimeSeccompProfileEnabled: true + disableManifestsDirectory: true + network: {} + install: + wipe: false + features: + rbac: true + stableHostname: true + apidCheckExtKeyUsage: true + diskQuotaSupport: true + kubePrism: + enabled: true + port: 7445 + hostDNS: + enabled: true + forwardKubeDNSToHost: true + nodeLabels: + node.kubernetes.io/exclude-from-external-load-balancers: "" +cluster: + id: 0raF93qnkMvF-FZNuvyGozXNdLiT2FOWSlyBaW4PR-w= + secret: pofHbABZq7VXuObsdLdy/bHmz6hlMHZ3p8+6WKrv1ic= + controlPlane: + endpoint: https://base:6443 + clusterName: base + network: + dnsDomain: cluster.local + podSubnets: + - 10.244.0.0/16 + serviceSubnets: + - 10.96.0.0/12 + token: inn7ol.u4ehnti8qyls9ymo + secretboxEncryptionSecret: 45yd2Ke+sytiICojDf8aibTfgt99nzJmO53cjDqrCto= + ca: + crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJpVENDQVMrZ0F3SUJBZ0lRYm1hNDNPalRwR0I5TjVxOVFEc3RFekFLQmdncWhrak9QUVFEQWpBVk1STXcKRVFZRFZRUUtFd3ByZFdKbGNtNWxkR1Z6TUI0WERUSXpNVEF4TWpFd05EWXdPVm9YRFRNek1UQXdPVEV3TkRZdwpPVm93RlRFVE1CRUdBMVVFQ2hNS2EzVmlaWEp1WlhSbGN6QlpNQk1HQnlxR1NNNDlBZ0VHQ0NxR1NNNDlBd0VICkEwSUFCTXNhRWZ5R3lFb0xyK0p1Wk91dkVVaXVNMStIQjZvZGtSdVV3ZEJ0ODdacDd1SkVoaEFsZitxNFFjT3gKcFRpZnBIRHJBOEFURjNCWUlFRmFXZ0xPTld1allUQmZNQTRHQTFVZER3RUIvd1FFQXdJQ2hEQWRCZ05WSFNVRQpGakFVQmdnckJnRUZCUWNEQVFZSUt3WUJCUVVIQXdJd0R3WURWUjBUQVFIL0JBVXdBd0VCL3pBZEJnTlZIUTRFCkZnUVU0ZEVkM1RoVzRKWlVWcXR1OEFZNWx1NUhQeGN3Q2dZSUtvWkl6ajBFQXdJRFNBQXdSUUloQUpJbkFMb0EKY1VhRUp4VlJ5dkhQenFQcTBvaGJOY2oyT3N2d3VKUFMzSktVQWlCSmhwNGFWMG9zUURRSGJnbjdXUWFYaHZFTwo5bWxTbVRURTAyOXBWb0YyWkE9PQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg== + key: LS0tLS1CRUdJTiBFQyBQUklWQVRFIEtFWS0tLS0tCk1IY0NBUUVFSUVZbFloNzVTUTZ6VUJFTUZ6em5pUzZuVVg3Q2VxQ013S3k0RTZHVEVFMGNvQW9HQ0NxR1NNNDkKQXdFSG9VUURRZ0FFeXhvUi9JYklTZ3V2NG01azY2OFJTSzR6WDRjSHFoMlJHNVRCMEczenRtbnU0a1NHRUNWLwo2cmhCdzdHbE9KK2tjT3NEd0JNWGNGZ2dRVnBhQXM0MWF3PT0KLS0tLS1FTkQgRUMgUFJJVkFURSBLRVktLS0tLQo= + aggregatorCA: + crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJYakNDQVFXZ0F3SUJBZ0lRWnNnVDRZZzVxRkNIbS9QTnV5QUVSekFLQmdncWhrak9QUVFEQWpBQU1CNFgKRFRJek1UQXhNakV3TkRZd09Wb1hEVE16TVRBd09URXdORFl3T1Zvd0FEQlpNQk1HQnlxR1NNNDlBZ0VHQ0NxRwpTTTQ5QXdFSEEwSUFCRmQ1eEhFWHhZRndQeTdaWjhmd3FHRGU2YVQ5ZmxNRVlWZENRNDlEaWZobWVteTVDaHZRCnlVRkpZcFM4b21HODVTS1dnOEpFTkoyNnhEdm9WMFBCS2srallUQmZNQTRHQTFVZER3RUIvd1FFQXdJQ2hEQWQKQmdOVkhTVUVGakFVQmdnckJnRUZCUWNEQVFZSUt3WUJCUVVIQXdJd0R3WURWUjBUQVFIL0JBVXdBd0VCL3pBZApCZ05WSFE0RUZnUVV4K0xab1FrYjlmOTN0Y0g4NnZjOUc2ZE13T2t3Q2dZSUtvWkl6ajBFQXdJRFJ3QXdSQUlnClhudDVXdmEzOGtWVTB3NjExMEp4bU43Qm5zcWl2NnNMaXlJNXRUR1BDQk1DSUZDQlJ3RXZSYTNnU3pkdXB6ajcKQVJLV3NlK3V5YW9rMnlNYXZnaUVITWpUCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K + key: LS0tLS1CRUdJTiBFQyBQUklWQVRFIEtFWS0tLS0tCk1IY0NBUUVFSUlMblhpQ3hOWU1CWHpncjVuYmc3bnVtUWM2UGlHaXdmWUN2eFF3Tlhxc3dvQW9HQ0NxR1NNNDkKQXdFSG9VUURRZ0FFVjNuRWNSZkZnWEEvTHRsbngvQ29ZTjdwcFAxK1V3UmhWMEpEajBPSitHWjZiTGtLRzlESgpRVWxpbEx5aVliemxJcGFEd2tRMG5ickVPK2hYUThFcVR3PT0KLS0tLS1FTkQgRUMgUFJJVkFURSBLRVktLS0tLQo= + serviceAccount: + key: LS0tLS1CRUdJTiBFQyBQUklWQVRFIEtFWS0tLS0tCk1IY0NBUUVFSUlHVElBQjZZUzV0cFcrUnYxeDBPY09Jb1h0SXgzdGZteVFZNGxOWWRCbmpvQW9HQ0NxR1NNNDkKQXdFSG9VUURRZ0FFQ3drbVVTUmtrbnlOc0NjTFJNUTlmZWx6cFY0dDdIdlNRcnp6ZGRvK2pWYmlqd2kwVVE1YQp0VW8vZkxQbDlBckVNOHNRWTVOSlgraVdxYjFkQWFXa2VnPT0KLS0tLS1FTkQgRUMgUFJJVkFURSBLRVktLS0tLQo= + apiServer: + image: registry.k8s.io/kube-apiserver:v1.28.0 + certSANs: + - base + disablePodSecurityPolicy: true + controllerManager: + image: registry.k8s.io/kube-controller-manager:v1.28.0 + proxy: + image: registry.k8s.io/kube-proxy:v1.28.0 + scheduler: + image: registry.k8s.io/kube-scheduler:v1.28.0 + discovery: + enabled: true + registries: + kubernetes: + disabled: true + service: {} + etcd: + ca: + crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJmVENDQVNPZ0F3SUJBZ0lRVkNTWmFQU3Z0TlZTcjYrVkRyUks0akFLQmdncWhrak9QUVFEQWpBUE1RMHcKQ3dZRFZRUUtFd1JsZEdOa01CNFhEVEl6TVRBeE1qRXdORFl3T1ZvWERUTXpNVEF3T1RFd05EWXdPVm93RHpFTgpNQXNHQTFVRUNoTUVaWFJqWkRCWk1CTUdCeXFHU000OUFnRUdDQ3FHU000OUF3RUhBMElBQk9wVXN0MHN3MEJZCkFDN0hpTGNrRElvdVdTRVhWTlJVWE42UmNLTWVRQU9VOEhJQkZBaTJlS2Rka2VJOEhZOTJNWTU1U21xQlhNK3cKRTh0RFgyT3kxSk9qWVRCZk1BNEdBMVVkRHdFQi93UUVBd0lDaERBZEJnTlZIU1VFRmpBVUJnZ3JCZ0VGQlFjRApBUVlJS3dZQkJRVUhBd0l3RHdZRFZSMFRBUUgvQkFVd0F3RUIvekFkQmdOVkhRNEVGZ1FVejVmai9oZTZoUjhMCkFRTU5qTjgxNS8zV3B6d3dDZ1lJS29aSXpqMEVBd0lEU0FBd1JRSWdFWWcyTlp3NkExek02eURNWTRHN1JPVkwKc0JOU0VhSDd4VmVSalBSblAvZ0NJUURiYzFMNmI0SkU0MCtuUCtYNG5pZlB0QWp5REhhUzVMS0YzQWZkUkRWdApMUT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K + key: LS0tLS1CRUdJTiBFQyBQUklWQVRFIEtFWS0tLS0tCk1IY0NBUUVFSU03Q2VnMk1GQW5TM3ROMzV6QTc0aFZ3VElkTkthK0ZwUHlYVERCdU4wVFlvQW9HQ0NxR1NNNDkKQXdFSG9VUURRZ0FFNmxTeTNTekRRRmdBTHNlSXR5UU1paTVaSVJkVTFGUmMzcEZ3b3g1QUE1VHdjZ0VVQ0xaNApwMTJSNGp3ZGozWXhqbmxLYW9GY3o3QVR5ME5mWTdMVWt3PT0KLS0tLS1FTkQgRUMgUFJJVkFURSBLRVktLS0tLQo= diff --git a/pkg/machinery/config/configpatcher/testdata/patchdelete/controlplane_orig.yaml b/pkg/machinery/config/configpatcher/testdata/patchdelete/controlplane_orig.yaml new file mode 100644 index 0000000000..5dbd72811a --- /dev/null +++ b/pkg/machinery/config/configpatcher/testdata/patchdelete/controlplane_orig.yaml @@ -0,0 +1,95 @@ +version: v1alpha1 +debug: false +persist: true +machine: + type: controlplane + token: d8cwfa.eyvpi0xwxyarbfid + ca: + crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJQakNCOGFBREFnRUNBaEI5cStGVXpodzkycHVPemtpNzB1eGRNQVVHQXl0bGNEQVFNUTR3REFZRFZRUUsKRXdWMFlXeHZjekFlRncweU16RXdNVEl4TURRMk1EbGFGdzB6TXpFd01Ea3hNRFEyTURsYU1CQXhEakFNQmdOVgpCQW9UQlhSaGJHOXpNQ293QlFZREsyVndBeUVBaHVLczZxeCtKWi8wWG8ybXdpQUNjK1EwSVYySGhMd3ozVTZICmUxemZjS2lqWVRCZk1BNEdBMVVkRHdFQi93UUVBd0lDaERBZEJnTlZIU1VFRmpBVUJnZ3JCZ0VGQlFjREFRWUkKS3dZQkJRVUhBd0l3RHdZRFZSMFRBUUgvQkFVd0F3RUIvekFkQmdOVkhRNEVGZ1FVSlgzWlVNRktWWFZ5NWhKWQozZG9NWENpVEJZRXdCUVlESzJWd0EwRUFCbUxrbDhITmQ3cUpEN3VqQkk2UG9abVRQQWlEcU9GQ0NTVDZJYlZDClF3UzQ1bk1tMldtalRIc3ZrYU5FQ0dneTBhQXJaaFdsbnVYWUswY0t3Z2VJQ0E9PQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg== + key: LS0tLS1CRUdJTiBFRDI1NTE5IFBSSVZBVEUgS0VZLS0tLS0KTUM0Q0FRQXdCUVlESzJWd0JDSUVJTURXbklEdVpSdlhQcW1tbSt6bk15SWMrdk53ZjdnYksvSmR3WC9iN2d1RQotLS0tLUVORCBFRDI1NTE5IFBSSVZBVEUgS0VZLS0tLS0K + certSANs: [] + kubelet: + image: ghcr.io/siderolabs/kubelet:v1.28.0 + defaultRuntimeSeccompProfileEnabled: true + disableManifestsDirectory: true + network: {} + install: + wipe: false + features: + rbac: true + stableHostname: true + apidCheckExtKeyUsage: true + diskQuotaSupport: true + kubePrism: + enabled: true + port: 7445 + hostDNS: + enabled: true + forwardKubeDNSToHost: true + nodeLabels: + node.kubernetes.io/exclude-from-external-load-balancers: "" +cluster: + id: 0raF93qnkMvF-FZNuvyGozXNdLiT2FOWSlyBaW4PR-w= + secret: pofHbABZq7VXuObsdLdy/bHmz6hlMHZ3p8+6WKrv1ic= + controlPlane: + endpoint: https://base:6443 + clusterName: base + network: + dnsDomain: cluster.local + podSubnets: + - 10.244.0.0/16 + serviceSubnets: + - 10.96.0.0/12 + token: inn7ol.u4ehnti8qyls9ymo + secretboxEncryptionSecret: 45yd2Ke+sytiICojDf8aibTfgt99nzJmO53cjDqrCto= + ca: + crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJpVENDQVMrZ0F3SUJBZ0lRYm1hNDNPalRwR0I5TjVxOVFEc3RFekFLQmdncWhrak9QUVFEQWpBVk1STXcKRVFZRFZRUUtFd3ByZFdKbGNtNWxkR1Z6TUI0WERUSXpNVEF4TWpFd05EWXdPVm9YRFRNek1UQXdPVEV3TkRZdwpPVm93RlRFVE1CRUdBMVVFQ2hNS2EzVmlaWEp1WlhSbGN6QlpNQk1HQnlxR1NNNDlBZ0VHQ0NxR1NNNDlBd0VICkEwSUFCTXNhRWZ5R3lFb0xyK0p1Wk91dkVVaXVNMStIQjZvZGtSdVV3ZEJ0ODdacDd1SkVoaEFsZitxNFFjT3gKcFRpZnBIRHJBOEFURjNCWUlFRmFXZ0xPTld1allUQmZNQTRHQTFVZER3RUIvd1FFQXdJQ2hEQWRCZ05WSFNVRQpGakFVQmdnckJnRUZCUWNEQVFZSUt3WUJCUVVIQXdJd0R3WURWUjBUQVFIL0JBVXdBd0VCL3pBZEJnTlZIUTRFCkZnUVU0ZEVkM1RoVzRKWlVWcXR1OEFZNWx1NUhQeGN3Q2dZSUtvWkl6ajBFQXdJRFNBQXdSUUloQUpJbkFMb0EKY1VhRUp4VlJ5dkhQenFQcTBvaGJOY2oyT3N2d3VKUFMzSktVQWlCSmhwNGFWMG9zUURRSGJnbjdXUWFYaHZFTwo5bWxTbVRURTAyOXBWb0YyWkE9PQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg== + key: LS0tLS1CRUdJTiBFQyBQUklWQVRFIEtFWS0tLS0tCk1IY0NBUUVFSUVZbFloNzVTUTZ6VUJFTUZ6em5pUzZuVVg3Q2VxQ013S3k0RTZHVEVFMGNvQW9HQ0NxR1NNNDkKQXdFSG9VUURRZ0FFeXhvUi9JYklTZ3V2NG01azY2OFJTSzR6WDRjSHFoMlJHNVRCMEczenRtbnU0a1NHRUNWLwo2cmhCdzdHbE9KK2tjT3NEd0JNWGNGZ2dRVnBhQXM0MWF3PT0KLS0tLS1FTkQgRUMgUFJJVkFURSBLRVktLS0tLQo= + aggregatorCA: + crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJYakNDQVFXZ0F3SUJBZ0lRWnNnVDRZZzVxRkNIbS9QTnV5QUVSekFLQmdncWhrak9QUVFEQWpBQU1CNFgKRFRJek1UQXhNakV3TkRZd09Wb1hEVE16TVRBd09URXdORFl3T1Zvd0FEQlpNQk1HQnlxR1NNNDlBZ0VHQ0NxRwpTTTQ5QXdFSEEwSUFCRmQ1eEhFWHhZRndQeTdaWjhmd3FHRGU2YVQ5ZmxNRVlWZENRNDlEaWZobWVteTVDaHZRCnlVRkpZcFM4b21HODVTS1dnOEpFTkoyNnhEdm9WMFBCS2srallUQmZNQTRHQTFVZER3RUIvd1FFQXdJQ2hEQWQKQmdOVkhTVUVGakFVQmdnckJnRUZCUWNEQVFZSUt3WUJCUVVIQXdJd0R3WURWUjBUQVFIL0JBVXdBd0VCL3pBZApCZ05WSFE0RUZnUVV4K0xab1FrYjlmOTN0Y0g4NnZjOUc2ZE13T2t3Q2dZSUtvWkl6ajBFQXdJRFJ3QXdSQUlnClhudDVXdmEzOGtWVTB3NjExMEp4bU43Qm5zcWl2NnNMaXlJNXRUR1BDQk1DSUZDQlJ3RXZSYTNnU3pkdXB6ajcKQVJLV3NlK3V5YW9rMnlNYXZnaUVITWpUCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K + key: LS0tLS1CRUdJTiBFQyBQUklWQVRFIEtFWS0tLS0tCk1IY0NBUUVFSUlMblhpQ3hOWU1CWHpncjVuYmc3bnVtUWM2UGlHaXdmWUN2eFF3Tlhxc3dvQW9HQ0NxR1NNNDkKQXdFSG9VUURRZ0FFVjNuRWNSZkZnWEEvTHRsbngvQ29ZTjdwcFAxK1V3UmhWMEpEajBPSitHWjZiTGtLRzlESgpRVWxpbEx5aVliemxJcGFEd2tRMG5ickVPK2hYUThFcVR3PT0KLS0tLS1FTkQgRUMgUFJJVkFURSBLRVktLS0tLQo= + serviceAccount: + key: LS0tLS1CRUdJTiBFQyBQUklWQVRFIEtFWS0tLS0tCk1IY0NBUUVFSUlHVElBQjZZUzV0cFcrUnYxeDBPY09Jb1h0SXgzdGZteVFZNGxOWWRCbmpvQW9HQ0NxR1NNNDkKQXdFSG9VUURRZ0FFQ3drbVVTUmtrbnlOc0NjTFJNUTlmZWx6cFY0dDdIdlNRcnp6ZGRvK2pWYmlqd2kwVVE1YQp0VW8vZkxQbDlBckVNOHNRWTVOSlgraVdxYjFkQWFXa2VnPT0KLS0tLS1FTkQgRUMgUFJJVkFURSBLRVktLS0tLQo= + apiServer: + image: registry.k8s.io/kube-apiserver:v1.28.0 + certSANs: + - base + disablePodSecurityPolicy: true + admissionControl: + - name: PodSecurity + configuration: + apiVersion: pod-security.admission.config.k8s.io/v1alpha1 + defaults: + audit: restricted + audit-version: latest + enforce: baseline + enforce-version: latest + warn: restricted + warn-version: latest + exemptions: + namespaces: + - kube-system + runtimeClasses: [] + usernames: [] + kind: PodSecurityConfiguration + auditPolicy: + apiVersion: audit.k8s.io/v1 + kind: Policy + rules: + - level: Metadata + controllerManager: + image: registry.k8s.io/kube-controller-manager:v1.28.0 + proxy: + image: registry.k8s.io/kube-proxy:v1.28.0 + scheduler: + image: registry.k8s.io/kube-scheduler:v1.28.0 + discovery: + enabled: true + registries: + kubernetes: + disabled: true + service: {} + etcd: + ca: + crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJmVENDQVNPZ0F3SUJBZ0lRVkNTWmFQU3Z0TlZTcjYrVkRyUks0akFLQmdncWhrak9QUVFEQWpBUE1RMHcKQ3dZRFZRUUtFd1JsZEdOa01CNFhEVEl6TVRBeE1qRXdORFl3T1ZvWERUTXpNVEF3T1RFd05EWXdPVm93RHpFTgpNQXNHQTFVRUNoTUVaWFJqWkRCWk1CTUdCeXFHU000OUFnRUdDQ3FHU000OUF3RUhBMElBQk9wVXN0MHN3MEJZCkFDN0hpTGNrRElvdVdTRVhWTlJVWE42UmNLTWVRQU9VOEhJQkZBaTJlS2Rka2VJOEhZOTJNWTU1U21xQlhNK3cKRTh0RFgyT3kxSk9qWVRCZk1BNEdBMVVkRHdFQi93UUVBd0lDaERBZEJnTlZIU1VFRmpBVUJnZ3JCZ0VGQlFjRApBUVlJS3dZQkJRVUhBd0l3RHdZRFZSMFRBUUgvQkFVd0F3RUIvekFkQmdOVkhRNEVGZ1FVejVmai9oZTZoUjhMCkFRTU5qTjgxNS8zV3B6d3dDZ1lJS29aSXpqMEVBd0lEU0FBd1JRSWdFWWcyTlp3NkExek02eURNWTRHN1JPVkwKc0JOU0VhSDd4VmVSalBSblAvZ0NJUURiYzFMNmI0SkU0MCtuUCtYNG5pZlB0QWp5REhhUzVMS0YzQWZkUkRWdApMUT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K + key: LS0tLS1CRUdJTiBFQyBQUklWQVRFIEtFWS0tLS0tCk1IY0NBUUVFSU03Q2VnMk1GQW5TM3ROMzV6QTc0aFZ3VElkTkthK0ZwUHlYVERCdU4wVFlvQW9HQ0NxR1NNNDkKQXdFSG9VUURRZ0FFNmxTeTNTekRRRmdBTHNlSXR5UU1paTVaSVJkVTFGUmMzcEZ3b3g1QUE1VHdjZ0VVQ0xaNApwMTJSNGp3ZGozWXhqbmxLYW9GY3o3QVR5ME5mWTdMVWt3PT0KLS0tLS1FTkQgRUMgUFJJVkFURSBLRVktLS0tLQo= diff --git a/pkg/machinery/config/configpatcher/testdata/patchdelete/expected.yaml b/pkg/machinery/config/configpatcher/testdata/patchdelete/expected.yaml new file mode 100644 index 0000000000..3c99bef22e --- /dev/null +++ b/pkg/machinery/config/configpatcher/testdata/patchdelete/expected.yaml @@ -0,0 +1,23 @@ +version: v1alpha1 +machine: + type: "" + token: "" + certSANs: [] + network: + interfaces: + - interface: eth1 + addresses: + - 10.3.5.4/32 + - 10.3.5.5/32 + - interface: eth0 + dummy: true +cluster: null +--- +apiVersion: v1alpha1 +kind: ExtensionServiceConfig +name: foo +configFiles: + - content: hello2 + mountPath: /etc/foo2 +environment: + - FOO=BAR diff --git a/pkg/machinery/config/configpatcher/testdata/patchdelete/strategic1.yaml b/pkg/machinery/config/configpatcher/testdata/patchdelete/strategic1.yaml new file mode 100644 index 0000000000..2b1b6b6ed3 --- /dev/null +++ b/pkg/machinery/config/configpatcher/testdata/patchdelete/strategic1.yaml @@ -0,0 +1,25 @@ +apiVersion: v1alpha1 +kind: SideroLinkConfig +$patch: delete +--- +apiVersion: v1alpha1 +kind: ExtensionServiceConfig +name: foo +configFiles: + - content: hello + $patch: delete + - content: hello2 + mountPath: /etc/foo2 +--- +version: v1alpha1 +machine: + network: + hostname: + $patch: delete + interfaces: + - interface: eth0 + $patch: delete + - interface: eth1 + addresses: [10.3.5.5/32] + - interface: eth0 + dummy: true diff --git a/pkg/machinery/config/configpatcher/testdata/patchdelete/strategic2.yaml b/pkg/machinery/config/configpatcher/testdata/patchdelete/strategic2.yaml new file mode 100644 index 0000000000..b1db44b65e --- /dev/null +++ b/pkg/machinery/config/configpatcher/testdata/patchdelete/strategic2.yaml @@ -0,0 +1,6 @@ +version: v1alpha1 +cluster: + apiServer: + admissionControl: + - name: PodSecurity + $patch: delete diff --git a/pkg/machinery/config/configpatcher/testdata/patchdelete/strategic3.yaml b/pkg/machinery/config/configpatcher/testdata/patchdelete/strategic3.yaml new file mode 100644 index 0000000000..fd08700989 --- /dev/null +++ b/pkg/machinery/config/configpatcher/testdata/patchdelete/strategic3.yaml @@ -0,0 +1,22 @@ +version: v1alpha1 +cluster: + apiServer: + admissionControl: + - name: PodSecurity2 + configuration: + apiVersion: pod-security.admission.config.k8s.io/v1alpha1 + defaults: + audit: restricted + audit-version: latest + enforce: baseline + enforce-version: latest + warn: restricted + warn-version: latest + exemptions: + namespaces: + - kube-system + runtimeClasses: [] + usernames: [] + kind: PodSecurityConfiguration + auditPolicy: + $patch: delete diff --git a/pkg/machinery/config/configpatcher/testdata/patchdelete/strategic4.yaml b/pkg/machinery/config/configpatcher/testdata/patchdelete/strategic4.yaml new file mode 100644 index 0000000000..6b3c093a8a --- /dev/null +++ b/pkg/machinery/config/configpatcher/testdata/patchdelete/strategic4.yaml @@ -0,0 +1,6 @@ +version: v1alpha1 +cluster: + apiServer: + admissionControl: + - name: PodSecurity2 + $patch: delete diff --git a/pkg/machinery/config/container/container.go b/pkg/machinery/config/container/container.go index 5557ae6fa5..d537fc4c77 100644 --- a/pkg/machinery/config/container/container.go +++ b/pkg/machinery/config/container/container.go @@ -9,7 +9,6 @@ import ( "bytes" "errors" "fmt" - "slices" "strings" "github.com/hashicorp/go-multierror" @@ -49,17 +48,19 @@ func New(documents ...config.Document) (*Container, error) { container.v1alpha1Config = d default: - documentID := d.Kind() + "/" + if _, ok := d.(selector); !ok { + documentID := d.Kind() + "/" - if named, ok := d.(config.NamedDocument); ok { - documentID += named.Name() - } + if named, ok := d.(config.NamedDocument); ok { + documentID += named.Name() + } - if _, alreadySeen := seenDocuments[documentID]; alreadySeen { - return nil, fmt.Errorf("duplicate document: %s", documentID) - } + if _, alreadySeen := seenDocuments[documentID]; alreadySeen { + return nil, fmt.Errorf("duplicate document: %s", documentID) + } - seenDocuments[documentID] = struct{}{} + seenDocuments[documentID] = struct{}{} + } container.documents = append(container.documents, d) } @@ -334,15 +335,34 @@ func (container *Container) RawV1Alpha1() *v1alpha1.Config { // // Documents should not be modified. func (container *Container) Documents() []config.Document { - docs := slices.Clone(container.documents) + result := make([]config.Document, 0, len(container.documents)+1) + + // first we take deletes for v1alpha1 + for _, doc := range container.documents { + if _, ok := doc.(selector); ok && doc.Kind() == v1alpha1.Version { + result = append(result, doc) + } + } + // then we take the v1alpha1 config if container.v1alpha1Config != nil { - docs = append([]config.Document{container.v1alpha1Config}, docs...) + result = append(result, container.v1alpha1Config) } - return docs + // then we take the rest + for _, doc := range container.documents { + if _, ok := doc.(selector); ok && doc.Kind() == v1alpha1.Version { + continue + } + + result = append(result, doc) + } + + return result } +type selector interface{ ApplyTo(config.Document) error } + // CompleteForBoot return true if the machine config is enough to proceed with the boot process. func (container *Container) CompleteForBoot() bool { // for now, v1alpha1 config is required diff --git a/website/content/v1.8/talos-guides/configuration/patching.md b/website/content/v1.8/talos-guides/configuration/patching.md index ebe664f2f0..cdf03d8a21 100644 --- a/website/content/v1.8/talos-guides/configuration/patching.md +++ b/website/content/v1.8/talos-guides/configuration/patching.md @@ -58,6 +58,57 @@ When patching a [multi-document machine configuration]({{< relref "../../referen - if the patch document doesn't exist in the machine configuration, it is appended to the machine configuration The strategic merge patch itself might be a multi-document YAML, and each document will be applied as a patch to the base machine configuration. +Keep in mind that you can't patch the same document multiple times with the same patch. + +You can also delete parts from the configuration using `$patch: delete` syntax similar to the +[Kubernetes](https://github.com/kubernetes/community/blob/master/contributors/devel/sig-api-machinery/strategic-merge-patch.md#delete-directive) +strategic merge patch. + +For example, with configuration: + +```yaml +machine: + network: + interfaces: + - interface: eth0 + addresses: + - 10.0.0.2/24 + hostname: worker1 +``` + +and patch document: + +```yaml +machine: + network: + interfaces: + - interface: eth0 + $patch: delete + hostname: worker1 +``` + +The resulting configuration will be: + +```yaml +machine: + network: + hostname: worker1 +``` + +You can also delete entire docs (but not the main `v1alpha1` configuration!) using this syntax: + +```yaml +apiVersion: v1alpha1 +kind: SideroLinkConfig +$patch: delete +--- +apiVersion: v1alpha1 +kind: ExtensionServiceConfig +name: foo +$patch: delete +``` + +This will remove the documents `SideroLinkConfig` and `ExtensionServiceConfig` with name `foo` from the configuration. ### RFC6902 (JSON Patches)