feat: implement "$patch: delete" logic

This PR implements "delete patches", same as in k8s.

Signed-off-by: Dmitriy Matrenichev <dmitry.matrenichev@siderolabs.com>
This commit is contained in:
Dmitriy Matrenichev 2024-09-05 00:52:24 +03:00
parent 545f75fd7a
commit 899f1b9004
No known key found for this signature in database
GPG Key ID: 94B473337258BFD5
22 changed files with 1186 additions and 50 deletions

View File

@ -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]

View File

@ -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

View File

@ -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 ""
}

View File

@ -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)
}

View File

@ -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 ""
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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

View File

@ -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

View File

@ -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))
})
}
}

View File

@ -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
}

View File

@ -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)

View File

@ -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

View File

@ -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=

View File

@ -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=

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,6 @@
version: v1alpha1
cluster:
apiServer:
admissionControl:
- name: PodSecurity
$patch: delete

View File

@ -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

View File

@ -0,0 +1,6 @@
version: v1alpha1
cluster:
apiServer:
admissionControl:
- name: PodSecurity2
$patch: delete

View File

@ -9,7 +9,6 @@ import (
"bytes"
"errors"
"fmt"
"slices"
"strings"
"github.com/hashicorp/go-multierror"
@ -49,18 +48,20 @@ 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)
}
seenDocuments[documentID] = struct{}{}
}
if _, alreadySeen := seenDocuments[documentID]; alreadySeen {
return nil, fmt.Errorf("duplicate document: %s", documentID)
}
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)
if container.v1alpha1Config != nil {
docs = append([]config.Document{container.v1alpha1Config}, docs...)
// first we take deletes for v1alpha1
for _, doc := range container.documents {
if _, ok := doc.(selector); ok && doc.Kind() == v1alpha1.Version {
result = append(result, doc)
}
}
return docs
// then we take the v1alpha1 config
if container.v1alpha1Config != nil {
result = append(result, container.v1alpha1Config)
}
// 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

View File

@ -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)