From a02bc81a565a034c98678dcf8d041b6c82a0db9e Mon Sep 17 00:00:00 2001 From: Fabian Wiesel Date: Thu, 12 Mar 2026 13:54:10 +0100 Subject: [PATCH] Add CheckDynamicResource and AlterDynamicResource plugins New plugin pair for operating on arbitrary Kubernetes objects using the unstructured/dynamic API with CEL as the query and modification language. CheckDynamicResource (checker): fetches a resource by GVK and evaluates a CEL expression to produce a boolean check result. AlterDynamicResource (trigger): fetches a resource by GVK, evaluates a CEL expression to produce a merge fragment, and patches the object with optimistic locking via resourceVersion. Both plugins support CEL expressions in name/namespace fields for dynamic resolution based on the current node. The CEL environment provides 'node' and 'object' variables as unstructured maps. --- controllers/config.go | 2 + docs/plugins.md | 32 ++ go.mod | 7 + go.sum | 15 + plugin/impl/dynamicresource.go | 474 ++++++++++++++++++++++ plugin/impl/dynamicresource_test.go | 605 ++++++++++++++++++++++++++++ 6 files changed, 1135 insertions(+) create mode 100644 plugin/impl/dynamicresource.go create mode 100644 plugin/impl/dynamicresource_test.go diff --git a/controllers/config.go b/controllers/config.go index 7431064c..6d85a16b 100644 --- a/controllers/config.go +++ b/controllers/config.go @@ -164,6 +164,7 @@ func addPluginsToRegistry(registry *plugin.Registry) { checkers := []plugin.Checker{ &impl.Affinity{}, &impl.AnyLabel{}, + &impl.CheckDynamicResource{}, &impl.CheckHypervisor{}, &impl.ClusterSemver{}, &impl.Condition{}, @@ -190,6 +191,7 @@ func addPluginsToRegistry(registry *plugin.Registry) { triggers := []plugin.Trigger{ &impl.AlterAnnotation{}, + &impl.AlterDynamicResource{}, &impl.AlterFinalizer{}, &impl.AlterHypervisor{}, &impl.AlterLabel{}, diff --git a/docs/plugins.md b/docs/plugins.md index 9194c86c..70c1eb73 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -42,6 +42,21 @@ config: : ``` +### checkDynamicResource +Fetches an arbitrary Kubernetes object identified by group, version, kind and name, then evaluates a [CEL](https://github.com/google/cel-go) expression against it. +The `name` and `namespace` fields can be plain strings or CEL expressions referencing the current node. +CEL expressions must be wrapped in `{{= }}`. +The CEL environment provides a `node` variable (the current node) and an `object` variable (the fetched resource) as unstructured maps. +```yaml +config: + group: the API group of the resource (e.g. "apps"), use empty string for core resources, optional + version: the API version of the resource (e.g. "v1"), required + kind: the kind of the resource (e.g. "ConfigMap"), required + namespace: the namespace of the resource, plain string or CEL expression, optional + name: the name of the resource, plain string or CEL expression, required + check: a CEL expression that must evaluate to a boolean, required +``` + ### clusterSemver Checks if a label containing a semantic version is less than the most up-to-date value in the cluster. Requires the checked node to have the specified label. @@ -183,6 +198,23 @@ config: remove: boolean value, if true the annotation is removed, if false the annotation is added or changed, optional ``` +### alterDynamicResource +Fetches an arbitrary Kubernetes object identified by group, version, kind and name, evaluates a [CEL](https://github.com/google/cel-go) expression to produce a merge fragment, and patches the object. +The `name` and `namespace` fields can be plain strings or CEL expressions referencing the current node. +CEL expressions must be wrapped in `{{= }}`. +The CEL environment provides a `node` variable (the current node) and an `object` variable (the fetched resource) as unstructured maps. +The `modify` expression must return a map that is deep-merged into the existing object. +Uses optimistic locking via `resourceVersion` to handle concurrent modifications. +```yaml +config: + group: the API group of the resource (e.g. "apps"), use empty string for core resources, optional + version: the API version of the resource (e.g. "v1"), required + kind: the kind of the resource (e.g. "ConfigMap"), required + namespace: the namespace of the resource, plain string or CEL expression, optional + name: the name of the resource, plain string or CEL expression, required + modify: a CEL expression that must evaluate to a map representing the fields to merge, required +``` + ### alterFinalizer Adds or removes a finalizer. ```yaml diff --git a/go.mod b/go.mod index cf6f3693..8020953e 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/cobaltcore-dev/openstack-hypervisor-operator v0.0.0-20260311150525-0bc36d8c1474 github.com/elastic/go-ucfg v0.9.1 github.com/go-logr/logr v1.4.3 + github.com/google/cel-go v0.26.0 github.com/gophercloud/gophercloud/v2 v2.11.1 github.com/onsi/ginkgo/v2 v2.28.1 github.com/onsi/gomega v1.39.1 @@ -28,7 +29,9 @@ require ( ) require ( + cel.dev/expr v0.24.0 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect @@ -60,10 +63,12 @@ require ( github.com/prometheus/procfs v0.17.0 // indirect github.com/shopspring/decimal v1.3.1 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/stoewer/go-strcase v1.3.1 // indirect github.com/x448/float16 v0.8.4 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 // indirect golang.org/x/mod v0.32.0 // indirect golang.org/x/net v0.49.0 // indirect golang.org/x/oauth2 v0.34.0 // indirect @@ -74,6 +79,8 @@ require ( golang.org/x/time v0.14.0 // indirect golang.org/x/tools v0.41.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250721164621-a45f3dfb1074 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/go.sum b/go.sum index 8ea118e5..65b280ee 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,13 @@ +cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= +cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/PaesslerAG/gval v1.2.4 h1:rhX7MpjJlcxYwL2eTTYIOBUyEKZ+A96T9vQySWkVUiU= github.com/PaesslerAG/gval v1.2.4/go.mod h1:XRFLwvmkTEdYziLdaCeCa5ImcGVrfQbeNUbVR+C6xac= github.com/PaesslerAG/jsonpath v0.1.0 h1:gADYeifvlqK3R3i2cR5B4DGgxLXIPb3TRTH1mGi0jPI= github.com/PaesslerAG/jsonpath v0.1.0/go.mod h1:4BzmtoM/PI8fPO4aQGIusjGxGir2BzcV0grWtFzq1Y8= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= @@ -56,6 +60,8 @@ github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9v github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI= +github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -134,6 +140,8 @@ github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stoewer/go-strcase v1.3.1 h1:iS0MdW+kVTxgMoE1LAZyMiYJFKlOzLooE4MxjirtkAs= +github.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -142,6 +150,7 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= @@ -167,6 +176,8 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 h1:R9PFI6EUdfVKgwKjZef7QIwGcBKu86OEFpJ9nUEP2l4= +golang.org/x/exp v0.0.0-20250718183923-645b1fa84792/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= @@ -187,6 +198,10 @@ golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +google.golang.org/genproto/googleapis/api v0.0.0-20250721164621-a45f3dfb1074 h1:mVXdvnmR3S3BQOqHECm9NGMjYiRtEvDYcqAqedTXY6s= +google.golang.org/genproto/googleapis/api v0.0.0-20250721164621-a45f3dfb1074/go.mod h1:vYFwMYFbmA8vl6Z/krj/h7+U/AqpHknwJX4Uqgfyc7I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4 h1:i8QOKZfYg6AbGVZzUAY3LrNWCKF8O6zFisU9Wl9RER4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/plugin/impl/dynamicresource.go b/plugin/impl/dynamicresource.go new file mode 100644 index 00000000..2984d2ba --- /dev/null +++ b/plugin/impl/dynamicresource.go @@ -0,0 +1,474 @@ +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company +// SPDX-License-Identifier: Apache-2.0 + +package impl + +import ( + "errors" + "fmt" + "reflect" + "strings" + + "github.com/google/cel-go/cel" + "github.com/google/cel-go/ext" + "github.com/sapcc/ucfgwrap" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/retry" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/sapcc/maintenance-controller/plugin" +) + +// celExprPrefix and celExprSuffix delimit a CEL expression in config fields. +const ( + celExprPrefix = "{{=" + celExprSuffix = "}}" +) + +// reflectMapType is reflect.Type for map[string]any, used by evalMap's ConvertToNative. +var reflectMapType = reflect.TypeFor[map[string]any]() + +// celField holds either a plain string value or a compiled CEL program. +type celField struct { + raw string + plain string + program cel.Program +} + +// parseCELField parses a config field value. If the value is wrapped in {{= }}, +// it is compiled as a CEL expression against the given environment. Otherwise it +// is treated as a literal string. +func parseCELField(raw string, env *cel.Env) (celField, error) { + if strings.HasPrefix(raw, celExprPrefix) && strings.HasSuffix(raw, celExprSuffix) { + expr := strings.TrimSpace(raw[len(celExprPrefix) : len(raw)-len(celExprSuffix)]) + ast, issues := env.Compile(expr) + if issues != nil && issues.Err() != nil { + return celField{}, fmt.Errorf("CEL compilation error: %w", issues.Err()) + } + prog, err := env.Program(ast) + if err != nil { + return celField{}, fmt.Errorf("CEL program error: %w", err) + } + return celField{raw: raw, program: prog}, nil + } + return celField{raw: raw, plain: raw}, nil +} + +// evalString evaluates the field as a string. For plain fields the literal value +// is returned. For CEL fields the expression is evaluated and the result must be +// a string. +func (f *celField) evalString(input map[string]any) (string, error) { + if f.program == nil { + return f.plain, nil + } + out, _, err := f.program.Eval(input) + if err != nil { + return "", fmt.Errorf("CEL evaluation error: %w", err) + } + s, ok := out.Value().(string) + if !ok { + return "", fmt.Errorf("CEL expression did not return a string, got %T", out.Value()) + } + return s, nil +} + +// evalBool evaluates the field as a boolean. The field must be a CEL expression. +func (f *celField) evalBool(input map[string]any) (bool, error) { + if f.program == nil { + return false, errors.New("field is not a CEL expression") + } + out, _, err := f.program.Eval(input) + if err != nil { + return false, fmt.Errorf("CEL evaluation error: %w", err) + } + b, ok := out.Value().(bool) + if !ok { + return false, fmt.Errorf("CEL expression did not return a bool, got %T", out.Value()) + } + return b, nil +} + +// evalMap evaluates the field as a map[string]any. The field must be a CEL expression. +// CEL's ConvertToNative only converts the top-level map; nested values may remain +// as CEL ref.Val types, so we recursively convert all values to native Go types. +func (f *celField) evalMap(input map[string]any) (map[string]any, error) { + if f.program == nil { + return nil, errors.New("field is not a CEL expression") + } + out, _, err := f.program.Eval(input) + if err != nil { + return nil, fmt.Errorf("CEL evaluation error: %w", err) + } + v, err := out.ConvertToNative(reflectMapType) + if err != nil { + return nil, fmt.Errorf("CEL expression result cannot be converted to map: %w", err) + } + m, ok := v.(map[string]any) + if !ok { + return nil, fmt.Errorf("CEL expression did not return a map, got %T", v) + } + return convertCELMap(m), nil +} + +// convertCELMap recursively converts a map that may contain CEL ref.Val types +// into plain Go types (map[string]any, []any, string, bool, int64, etc.). +func convertCELMap(m map[string]any) map[string]any { + result := make(map[string]any, len(m)) + for k, v := range m { + result[k] = convertCELValue(v) + } + return result +} + +// convertCELValue converts a single value that may be a CEL ref.Val into a native Go value. +// CEL's ConvertToNative only converts the outermost map to map[string]any — nested maps +// may remain as map[ref.Val]ref.Val (or other reflect.Map types). We use reflection to +// detect and convert arbitrary map and slice types recursively. +func convertCELValue(v any) any { + if v == nil { + return nil + } + + // Fast path: already-native types + switch val := v.(type) { + case map[string]any: + return convertCELMap(val) + case []any: + result := make([]any, len(val)) + for i, item := range val { + result[i] = convertCELValue(item) + } + return result + case string, bool, int64, float64, uint64: + return v + } + + // Try the ref.Val interface for CEL wrapper types (e.g. types.String, types.Bool) + if refVal, ok := v.(interface{ Value() any }); ok { + return convertCELValue(refVal.Value()) + } + + // Reflection path: handle map[ref.Val]ref.Val and similar exotic map types + rv := reflect.ValueOf(v) + switch rv.Kind() { //nolint:exhaustive + case reflect.Map: + result := make(map[string]any, rv.Len()) + iter := rv.MapRange() + for iter.Next() { + key := convertCELValue(iter.Key().Interface()) + keyStr, ok := key.(string) + if !ok { + keyStr = fmt.Sprintf("%v", key) + } + result[keyStr] = convertCELValue(iter.Value().Interface()) + } + return result + case reflect.Slice: + result := make([]any, rv.Len()) + for i := range rv.Len() { + result[i] = convertCELValue(rv.Index(i).Interface()) + } + return result + } + + return v +} + +// nodeToMap converts a Kubernetes Node to an unstructured map. +func nodeToMap(node *corev1.Node) (map[string]any, error) { + m, err := runtime.DefaultUnstructuredConverter.ToUnstructured(node) + if err != nil { + return nil, fmt.Errorf("failed to convert node to unstructured: %w", err) + } + return m, nil +} + +// deepMerge recursively merges src into dst. For overlapping keys where both +// values are maps, the merge recurses. Otherwise src values overwrite dst values. +// Non-overlapping keys are preserved from both sides. +func deepMerge(dst, src map[string]any) map[string]any { + for k, srcVal := range src { + dstVal, exists := dst[k] + if !exists { + dst[k] = srcVal + continue + } + dstMap, dstIsMap := dstVal.(map[string]any) + srcMap, srcIsMap := srcVal.(map[string]any) + if dstIsMap && srcIsMap { + dst[k] = deepMerge(dstMap, srcMap) + } else { + dst[k] = srcVal + } + } + return dst +} + +// newRefEnv creates a CEL environment with only a "node" variable (dyn type). +// Used for resolving name/namespace fields. +func newRefEnv() (*cel.Env, error) { + return cel.NewEnv( + cel.Variable("node", cel.DynType), + ext.Strings(), + ext.Lists(), + ext.Sets(), + ext.Math(), + ext.Encoders(), + ) +} + +// newEvalEnv creates a CEL environment with "node" and "object" variables (dyn type). +// Used for check/modify expressions. +func newEvalEnv() (*cel.Env, error) { + return cel.NewEnv( + cel.Variable("node", cel.DynType), + cel.Variable("object", cel.DynType), + ext.Strings(), + ext.Lists(), + ext.Sets(), + ext.Math(), + ext.Encoders(), + ) +} + +// CheckDynamicResource is a check plugin that fetches an arbitrary Kubernetes +// object using GVK and evaluates a CEL expression against it. +type CheckDynamicResource struct { + group string + version string + kind string + namespace celField + name celField + check celField +} + +// New creates a new CheckDynamicResource instance with the given config. +func (c *CheckDynamicResource) New(config *ucfgwrap.Config) (plugin.Checker, error) { + conf := struct { + Group string `config:"group"` + Version string `config:"version" validate:"required"` + Kind string `config:"kind" validate:"required"` + Namespace string `config:"namespace"` + Name string `config:"name" validate:"required"` + Check string `config:"check" validate:"required"` + }{} + if err := config.Unpack(&conf); err != nil { + return nil, err + } + + // Validate that check uses CEL syntax + if !strings.HasPrefix(conf.Check, celExprPrefix) || !strings.HasSuffix(conf.Check, celExprSuffix) { + return nil, errors.New("check field must be a CEL expression wrapped in {{= }}") + } + + refEnv, err := newRefEnv() + if err != nil { + return nil, fmt.Errorf("failed to create CEL ref environment: %w", err) + } + evalEnv, err := newEvalEnv() + if err != nil { + return nil, fmt.Errorf("failed to create CEL eval environment: %w", err) + } + + nameField, err := parseCELField(conf.Name, refEnv) + if err != nil { + return nil, fmt.Errorf("failed to parse name field: %w", err) + } + var nsField celField + if conf.Namespace != "" { + nsField, err = parseCELField(conf.Namespace, refEnv) + if err != nil { + return nil, fmt.Errorf("failed to parse namespace field: %w", err) + } + } + checkField, err := parseCELField(conf.Check, evalEnv) + if err != nil { + return nil, fmt.Errorf("failed to parse check field: %w", err) + } + + return &CheckDynamicResource{ + group: conf.Group, + version: conf.Version, + kind: conf.Kind, + namespace: nsField, + name: nameField, + check: checkField, + }, nil +} + +// ID returns the plugin identifier. +func (c *CheckDynamicResource) ID() string { + return "checkDynamicResource" +} + +// Check fetches the target Kubernetes object and evaluates the CEL check expression. +func (c *CheckDynamicResource) Check(params plugin.Parameters) (plugin.CheckResult, error) { + nodeMap, err := nodeToMap(params.Node) + if err != nil { + return plugin.Failed(nil), err + } + refInput := map[string]any{"node": nodeMap} + + name, err := c.name.evalString(refInput) + if err != nil { + return plugin.Failed(nil), fmt.Errorf("failed to resolve name: %w", err) + } + namespace, err := c.namespace.evalString(refInput) + if err != nil { + return plugin.Failed(nil), fmt.Errorf("failed to resolve namespace: %w", err) + } + + obj := &unstructured.Unstructured{} + obj.SetGroupVersionKind(schema.GroupVersionKind{ + Group: c.group, + Version: c.version, + Kind: c.kind, + }) + + if err := params.Client.Get(params.Ctx, types.NamespacedName{ + Namespace: namespace, + Name: name, + }, obj); err != nil { + return plugin.Failed(nil), err + } + + evalInput := map[string]any{ + "node": nodeMap, + "object": obj.UnstructuredContent(), + } + passed, err := c.check.evalBool(evalInput) + if err != nil { + return plugin.Failed(nil), err + } + if passed { + return plugin.Passed(nil), nil + } + return plugin.Failed(nil), nil +} + +// OnTransition is a no-op for this plugin. +func (c *CheckDynamicResource) OnTransition(params plugin.Parameters) error { + return nil +} + +// AlterDynamicResource is a trigger plugin that fetches an arbitrary Kubernetes +// object using GVK, evaluates a CEL expression to produce a merge fragment, +// and patches the object. +type AlterDynamicResource struct { + group string + version string + kind string + namespace celField + name celField + modify celField +} + +// New creates a new AlterDynamicResource instance with the given config. +func (a *AlterDynamicResource) New(config *ucfgwrap.Config) (plugin.Trigger, error) { + conf := struct { + Group string `config:"group"` + Version string `config:"version" validate:"required"` + Kind string `config:"kind" validate:"required"` + Namespace string `config:"namespace"` + Name string `config:"name" validate:"required"` + Modify string `config:"modify" validate:"required"` + }{} + if err := config.Unpack(&conf); err != nil { + return nil, err + } + + // Validate that modify uses CEL syntax + if !strings.HasPrefix(conf.Modify, celExprPrefix) || !strings.HasSuffix(conf.Modify, celExprSuffix) { + return nil, errors.New("modify field must be a CEL expression wrapped in {{= }}") + } + + refEnv, err := newRefEnv() + if err != nil { + return nil, fmt.Errorf("failed to create CEL ref environment: %w", err) + } + evalEnv, err := newEvalEnv() + if err != nil { + return nil, fmt.Errorf("failed to create CEL eval environment: %w", err) + } + + nameField, err := parseCELField(conf.Name, refEnv) + if err != nil { + return nil, fmt.Errorf("failed to parse name field: %w", err) + } + var nsField celField + if conf.Namespace != "" { + nsField, err = parseCELField(conf.Namespace, refEnv) + if err != nil { + return nil, fmt.Errorf("failed to parse namespace field: %w", err) + } + } + modifyField, err := parseCELField(conf.Modify, evalEnv) + if err != nil { + return nil, fmt.Errorf("failed to parse modify field: %w", err) + } + + return &AlterDynamicResource{ + group: conf.Group, + version: conf.Version, + kind: conf.Kind, + namespace: nsField, + name: nameField, + modify: modifyField, + }, nil +} + +// ID returns the plugin identifier. +func (a *AlterDynamicResource) ID() string { + return "alterDynamicResource" +} + +// Trigger fetches the target Kubernetes object, evaluates the CEL modify +// expression to produce a merge fragment, deep-merges it into the object, +// and patches with optimistic locking. +func (a *AlterDynamicResource) Trigger(params plugin.Parameters) error { + nodeMap, err := nodeToMap(params.Node) + if err != nil { + return err + } + refInput := map[string]any{"node": nodeMap} + + name, err := a.name.evalString(refInput) + if err != nil { + return fmt.Errorf("failed to resolve name: %w", err) + } + namespace, err := a.namespace.evalString(refInput) + if err != nil { + return fmt.Errorf("failed to resolve namespace: %w", err) + } + + key := types.NamespacedName{Namespace: namespace, Name: name} + gvk := schema.GroupVersionKind{Group: a.group, Version: a.version, Kind: a.kind} + + return retry.RetryOnConflict(retry.DefaultRetry, func() error { + obj := &unstructured.Unstructured{} + obj.SetGroupVersionKind(gvk) + if err := params.Client.Get(params.Ctx, key, obj); err != nil { + return err + } + + orig := obj.DeepCopy() + + evalInput := map[string]any{ + "node": nodeMap, + "object": obj.UnstructuredContent(), + } + fragment, err := a.modify.evalMap(evalInput) + if err != nil { + return err + } + + deepMerge(obj.UnstructuredContent(), fragment) + + return params.Client.Patch(params.Ctx, obj, + client.MergeFromWithOptions(orig, client.MergeFromWithOptimisticLock{})) + }) +} diff --git a/plugin/impl/dynamicresource_test.go b/plugin/impl/dynamicresource_test.go new file mode 100644 index 00000000..c328c426 --- /dev/null +++ b/plugin/impl/dynamicresource_test.go @@ -0,0 +1,605 @@ +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company +// SPDX-License-Identifier: Apache-2.0 + +package impl + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/sapcc/ucfgwrap" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/sapcc/maintenance-controller/plugin" +) + +var _ = Describe("The dynamic resource plugins", func() { + + Describe("deepMerge", func() { + It("merges non-overlapping keys", func() { + dst := map[string]any{"a": "1"} + src := map[string]any{"b": "2"} + result := deepMerge(dst, src) + Expect(result).To(Equal(map[string]any{"a": "1", "b": "2"})) + }) + + It("overwrites leaves on conflict", func() { + dst := map[string]any{"a": "old"} + src := map[string]any{"a": "new"} + result := deepMerge(dst, src) + Expect(result).To(Equal(map[string]any{"a": "new"})) + }) + + It("merges nested maps recursively", func() { + dst := map[string]any{ + "data": map[string]any{"x": "1", "y": "2"}, + } + src := map[string]any{ + "data": map[string]any{"y": "changed", "z": "3"}, + } + result := deepMerge(dst, src) + Expect(result).To(Equal(map[string]any{ + "data": map[string]any{"x": "1", "y": "changed", "z": "3"}, + })) + }) + + It("overwrites non-map with map", func() { + dst := map[string]any{"data": "string-value"} + src := map[string]any{"data": map[string]any{"key": "val"}} + result := deepMerge(dst, src) + Expect(result).To(Equal(map[string]any{ + "data": map[string]any{"key": "val"}, + })) + }) + }) + + Describe("celField parsing", func() { + It("returns a plain field for non-CEL values", func() { + env, err := newRefEnv() + Expect(err).To(Succeed()) + + field, err := parseCELField("plain-string", env) + Expect(err).To(Succeed()) + Expect(field.program).To(BeNil()) + Expect(field.plain).To(Equal("plain-string")) + }) + + It("compiles a CEL expression wrapped in {{= }}", func() { + env, err := newRefEnv() + Expect(err).To(Succeed()) + + field, err := parseCELField("{{= node.metadata.name }}", env) + Expect(err).To(Succeed()) + Expect(field.program).ToNot(BeNil()) + Expect(field.plain).To(BeEmpty()) + }) + + It("rejects invalid CEL expressions", func() { + env, err := newRefEnv() + Expect(err).To(Succeed()) + + _, err = parseCELField("{{= !!!invalid!! }}", env) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("CEL compilation error")) + }) + + It("evaluates a CEL expression to a string", func() { + env, err := newRefEnv() + Expect(err).To(Succeed()) + + field, err := parseCELField("{{= node.metadata.name }}", env) + Expect(err).To(Succeed()) + + input := map[string]any{ + "node": map[string]any{ + "metadata": map[string]any{ + "name": "test-node", + }, + }, + } + result, err := field.evalString(input) + Expect(err).To(Succeed()) + Expect(result).To(Equal("test-node")) + }) + + It("returns plain value for evalString on non-CEL field", func() { + env, err := newRefEnv() + Expect(err).To(Succeed()) + + field, err := parseCELField("literal-value", env) + Expect(err).To(Succeed()) + + result, err := field.evalString(nil) + Expect(err).To(Succeed()) + Expect(result).To(Equal("literal-value")) + }) + + It("evaluates a CEL expression to a bool", func() { + env, err := newEvalEnv() + Expect(err).To(Succeed()) + + field, err := parseCELField("{{= object.data.ready == \"true\" }}", env) + Expect(err).To(Succeed()) + + input := map[string]any{ + "node": map[string]any{}, + "object": map[string]any{"data": map[string]any{"ready": "true"}}, + } + result, err := field.evalBool(input) + Expect(err).To(Succeed()) + Expect(result).To(BeTrue()) + }) + + It("returns error for evalBool on non-bool CEL result", func() { + env, err := newEvalEnv() + Expect(err).To(Succeed()) + + field, err := parseCELField("{{= \"not-a-bool\" }}", env) + Expect(err).To(Succeed()) + + _, err = field.evalBool(map[string]any{"node": map[string]any{}, "object": map[string]any{}}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("did not return a bool")) + }) + + It("evaluates a CEL expression to a map", func() { + env, err := newEvalEnv() + Expect(err).To(Succeed()) + + field, err := parseCELField(`{{= {"data": {"key": "value"}} }}`, env) + Expect(err).To(Succeed()) + + input := map[string]any{ + "node": map[string]any{}, + "object": map[string]any{}, + } + result, err := field.evalMap(input) + Expect(err).To(Succeed()) + Expect(result).To(Equal(map[string]any{ + "data": map[string]any{"key": "value"}, + })) + }) + }) + + Describe("CheckDynamicResource", func() { + var k8sClient client.Client + var testNode *corev1.Node + + BeforeEach(func() { + scheme := runtime.NewScheme() + Expect(corev1.AddToScheme(scheme)).To(Succeed()) + k8sClient = fake.NewClientBuilder().WithScheme(scheme).Build() + testNode = &corev1.Node{ObjectMeta: metav1.ObjectMeta{Name: "test-node"}} + }) + + It("parses valid config with plain name and namespace", func() { + configStr := `version: v1 +kind: ConfigMap +namespace: kube-system +name: my-config +check: "{{= object.data.ready == \"true\" }}" +` + config, err := ucfgwrap.FromYAML([]byte(configStr)) + Expect(err).To(Succeed()) + + var base CheckDynamicResource + _, err = base.New(&config) + Expect(err).To(Succeed()) + }) + + It("parses valid config with CEL name", func() { + configStr := `version: v1 +kind: ConfigMap +namespace: kube-system +name: "{{= node.metadata.name }}" +check: "{{= object.data.ready == \"true\" }}" +` + config, err := ucfgwrap.FromYAML([]byte(configStr)) + Expect(err).To(Succeed()) + + var base CheckDynamicResource + _, err = base.New(&config) + Expect(err).To(Succeed()) + }) + + It("rejects config without required fields", func() { + configStr := `version: v1` + config, err := ucfgwrap.FromYAML([]byte(configStr)) + Expect(err).To(Succeed()) + + var base CheckDynamicResource + _, err = base.New(&config) + Expect(err).To(HaveOccurred()) + }) + + It("rejects check field without CEL delimiters", func() { + configStr := `version: v1 +kind: ConfigMap +name: my-config +check: not-a-cel-expression +` + config, err := ucfgwrap.FromYAML([]byte(configStr)) + Expect(err).To(Succeed()) + + var base CheckDynamicResource + _, err = base.New(&config) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("CEL expression")) + }) + + It("rejects invalid CEL in check field", func() { + configStr := `version: v1 +kind: ConfigMap +name: my-config +check: "{{= !!!invalid }}" +` + config, err := ucfgwrap.FromYAML([]byte(configStr)) + Expect(err).To(Succeed()) + + var base CheckDynamicResource + _, err = base.New(&config) + Expect(err).To(HaveOccurred()) + }) + + It("passes when check expression evaluates to true", func(ctx SpecContext) { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-config", + Namespace: "default", + }, + Data: map[string]string{ + "ready": "true", + }, + } + Expect(k8sClient.Create(ctx, cm)).To(Succeed()) + + configStr := `version: v1 +kind: ConfigMap +namespace: default +name: my-config +check: "{{= object.data.ready == \"true\" }}" +` + config, err := ucfgwrap.FromYAML([]byte(configStr)) + Expect(err).To(Succeed()) + + var base CheckDynamicResource + checker, err := base.New(&config) + Expect(err).To(Succeed()) + + result, err := checker.Check(plugin.Parameters{ + Client: k8sClient, + Ctx: ctx, + Node: testNode, + }) + Expect(err).To(Succeed()) + Expect(result.Passed).To(BeTrue()) + }) + + It("fails when check expression evaluates to false", func(ctx SpecContext) { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-config", + Namespace: "default", + }, + Data: map[string]string{ + "ready": "false", + }, + } + Expect(k8sClient.Create(ctx, cm)).To(Succeed()) + + configStr := `version: v1 +kind: ConfigMap +namespace: default +name: my-config +check: "{{= object.data.ready == \"true\" }}" +` + config, err := ucfgwrap.FromYAML([]byte(configStr)) + Expect(err).To(Succeed()) + + var base CheckDynamicResource + checker, err := base.New(&config) + Expect(err).To(Succeed()) + + result, err := checker.Check(plugin.Parameters{ + Client: k8sClient, + Ctx: ctx, + Node: testNode, + }) + Expect(err).To(Succeed()) + Expect(result.Passed).To(BeFalse()) + }) + + It("returns error when object is not found", func(ctx SpecContext) { + configStr := `version: v1 +kind: ConfigMap +namespace: default +name: nonexistent +check: "{{= true }}" +` + config, err := ucfgwrap.FromYAML([]byte(configStr)) + Expect(err).To(Succeed()) + + var base CheckDynamicResource + checker, err := base.New(&config) + Expect(err).To(Succeed()) + + result, err := checker.Check(plugin.Parameters{ + Client: k8sClient, + Ctx: ctx, + Node: testNode, + }) + Expect(err).To(HaveOccurred()) + Expect(result.Passed).To(BeFalse()) + }) + + It("resolves name from node via CEL", func(ctx SpecContext) { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-node", + Namespace: "default", + }, + Data: map[string]string{ + "status": "ok", + }, + } + Expect(k8sClient.Create(ctx, cm)).To(Succeed()) + + configStr := `version: v1 +kind: ConfigMap +namespace: default +name: "{{= node.metadata.name }}" +check: "{{= object.data.status == \"ok\" }}" +` + config, err := ucfgwrap.FromYAML([]byte(configStr)) + Expect(err).To(Succeed()) + + var base CheckDynamicResource + checker, err := base.New(&config) + Expect(err).To(Succeed()) + + result, err := checker.Check(plugin.Parameters{ + Client: k8sClient, + Ctx: ctx, + Node: testNode, + }) + Expect(err).To(Succeed()) + Expect(result.Passed).To(BeTrue()) + }) + + It("works with group/version/kind for non-core resources", func(ctx SpecContext) { + // Create an unstructured object with a custom GVK + obj := &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]any{ + "name": "coredns", + "namespace": "kube-system", + }, + "status": map[string]any{ + "availableReplicas": int64(2), + }, + }, + } + // Register the GVK with the scheme so the fake client can handle it + scheme := runtime.NewScheme() + Expect(corev1.AddToScheme(scheme)).To(Succeed()) + scheme.AddKnownTypeWithName( + schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"}, + &unstructured.Unstructured{}, + ) + scheme.AddKnownTypeWithName( + schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "DeploymentList"}, + &unstructured.UnstructuredList{}, + ) + k8sClient = fake.NewClientBuilder().WithScheme(scheme).WithObjects(obj).Build() + + configStr := `group: apps +version: v1 +kind: Deployment +namespace: kube-system +name: coredns +check: "{{= int(object.status.availableReplicas) >= 2 }}" +` + config, err := ucfgwrap.FromYAML([]byte(configStr)) + Expect(err).To(Succeed()) + + var base CheckDynamicResource + checker, err := base.New(&config) + Expect(err).To(Succeed()) + + result, err := checker.Check(plugin.Parameters{ + Client: k8sClient, + Ctx: ctx, + Node: testNode, + }) + Expect(err).To(Succeed()) + Expect(result.Passed).To(BeTrue()) + }) + + It("has correct ID", func() { + c := &CheckDynamicResource{} + Expect(c.ID()).To(Equal("checkDynamicResource")) + }) + + It("OnTransition is a no-op", func() { + c := &CheckDynamicResource{} + err := c.OnTransition(plugin.Parameters{}) + Expect(err).To(Succeed()) + }) + }) + + Describe("AlterDynamicResource", func() { + var k8sClient client.Client + var testNode *corev1.Node + + BeforeEach(func() { + scheme := runtime.NewScheme() + Expect(corev1.AddToScheme(scheme)).To(Succeed()) + k8sClient = fake.NewClientBuilder().WithScheme(scheme).Build() + testNode = &corev1.Node{ObjectMeta: metav1.ObjectMeta{Name: "test-node"}} + }) + + It("parses valid config", func() { + configStr := `version: v1 +kind: ConfigMap +namespace: default +name: my-config +modify: '{{= {"data": {"key": "value"}} }}' +` + config, err := ucfgwrap.FromYAML([]byte(configStr)) + Expect(err).To(Succeed()) + + var base AlterDynamicResource + _, err = base.New(&config) + Expect(err).To(Succeed()) + }) + + It("rejects config without required fields", func() { + configStr := `version: v1` + config, err := ucfgwrap.FromYAML([]byte(configStr)) + Expect(err).To(Succeed()) + + var base AlterDynamicResource + _, err = base.New(&config) + Expect(err).To(HaveOccurred()) + }) + + It("rejects modify field without CEL delimiters", func() { + configStr := `version: v1 +kind: ConfigMap +name: my-config +modify: not-a-cel-expression +` + config, err := ucfgwrap.FromYAML([]byte(configStr)) + Expect(err).To(Succeed()) + + var base AlterDynamicResource + _, err = base.New(&config) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("CEL expression")) + }) + + It("applies merge fragment to object", func(ctx SpecContext) { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-config", + Namespace: "default", + }, + Data: map[string]string{ + "existing": "value", + }, + } + Expect(k8sClient.Create(ctx, cm)).To(Succeed()) + + configStr := `version: v1 +kind: ConfigMap +namespace: default +name: my-config +modify: '{{= {"data": {"node": node.metadata.name}} }}' +` + config, err := ucfgwrap.FromYAML([]byte(configStr)) + Expect(err).To(Succeed()) + + var base AlterDynamicResource + trigger, err := base.New(&config) + Expect(err).To(Succeed()) + + err = trigger.Trigger(plugin.Parameters{ + Client: k8sClient, + Ctx: ctx, + Node: testNode, + }) + Expect(err).To(Succeed()) + + // Verify the object was updated + updated := &unstructured.Unstructured{} + updated.SetGroupVersionKind(schema.GroupVersionKind{Version: "v1", Kind: "ConfigMap"}) + Expect(k8sClient.Get(ctx, client.ObjectKey{ + Namespace: "default", + Name: "my-config", + }, updated)).To(Succeed()) + + data, found, err := unstructured.NestedStringMap(updated.Object, "data") + Expect(err).To(Succeed()) + Expect(found).To(BeTrue()) + Expect(data["node"]).To(Equal("test-node")) + }) + + It("returns error when object is not found", func(ctx SpecContext) { + configStr := `version: v1 +kind: ConfigMap +namespace: default +name: nonexistent +modify: '{{= {"data": {"key": "value"}} }}' +` + config, err := ucfgwrap.FromYAML([]byte(configStr)) + Expect(err).To(Succeed()) + + var base AlterDynamicResource + trigger, err := base.New(&config) + Expect(err).To(Succeed()) + + err = trigger.Trigger(plugin.Parameters{ + Client: k8sClient, + Ctx: ctx, + Node: testNode, + }) + Expect(err).To(HaveOccurred()) + }) + + It("resolves name from node via CEL", func(ctx SpecContext) { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-node", + Namespace: "default", + }, + Data: map[string]string{}, + } + Expect(k8sClient.Create(ctx, cm)).To(Succeed()) + + configStr := `version: v1 +kind: ConfigMap +namespace: default +name: "{{= node.metadata.name }}" +modify: '{{= {"data": {"modified": "yes"}} }}' +` + config, err := ucfgwrap.FromYAML([]byte(configStr)) + Expect(err).To(Succeed()) + + var base AlterDynamicResource + trigger, err := base.New(&config) + Expect(err).To(Succeed()) + + err = trigger.Trigger(plugin.Parameters{ + Client: k8sClient, + Ctx: ctx, + Node: testNode, + }) + Expect(err).To(Succeed()) + + // Verify the object was updated + updated := &unstructured.Unstructured{} + updated.SetGroupVersionKind(schema.GroupVersionKind{Version: "v1", Kind: "ConfigMap"}) + Expect(k8sClient.Get(ctx, client.ObjectKey{ + Namespace: "default", + Name: "test-node", + }, updated)).To(Succeed()) + + data, found, err := unstructured.NestedStringMap(updated.Object, "data") + Expect(err).To(Succeed()) + Expect(found).To(BeTrue()) + Expect(data["modified"]).To(Equal("yes")) + }) + + It("has correct ID", func() { + a := &AlterDynamicResource{} + Expect(a.ID()).To(Equal("alterDynamicResource")) + }) + }) +})