Merge pull request #6452 from anshulahuja98/resourcemodifier

Add support for ResourceModifier (AKA Json Substitutions) in restore flow
This commit is contained in:
Daniel Jiang
2023-07-19 16:11:54 +08:00
committed by GitHub
15 changed files with 1352 additions and 36 deletions
+1
View File
@@ -53,3 +53,4 @@ tilt-resources/cloud
# test generated files
test/e2e/report.xml
coverage.out
__debug_bin*
+1
View File
@@ -0,0 +1 @@
Add support for resource Modifications in the restore flow. Also known as JSON Substitutions.
@@ -359,6 +359,28 @@ spec:
from backup.
nullable: true
type: boolean
resourceModifier:
description: ResourceModifier specifies the reference to JSON resource
patches that should be applied to resources before restoration.
nullable: true
properties:
apiGroup:
description: APIGroup is the group for the resource being referenced.
If APIGroup is not specified, the specified Kind must be in
the core API group. For any other third-party types, APIGroup
is required.
type: string
kind:
description: Kind is the type of resource being referenced
type: string
name:
description: Name is the name of resource being referenced
type: string
required:
- kind
- name
type: object
x-kubernetes-map-type: atomic
restorePVs:
description: RestorePVs specifies whether to restore all included
PVs from snapshot
File diff suppressed because one or more lines are too long
@@ -0,0 +1,164 @@
package resourcemodifiers
import (
"fmt"
"io"
"regexp"
"strings"
jsonpatch "github.com/evanphx/json-patch"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"github.com/vmware-tanzu/velero/pkg/util/collections"
)
const (
ConfigmapRefType = "configmap"
ResourceModifierSupportedVersionV1 = "v1"
)
type JSONPatch struct {
Operation string `yaml:"operation"`
From string `yaml:"from,omitempty"`
Path string `yaml:"path"`
Value string `yaml:"value,omitempty"`
}
type Conditions struct {
Namespaces []string `yaml:"namespaces,omitempty"`
GroupKind string `yaml:"groupKind"`
ResourceNameRegex string `yaml:"resourceNameRegex"`
}
type ResourceModifierRule struct {
Conditions Conditions `yaml:"conditions"`
Patches []JSONPatch `yaml:"patches"`
}
type ResourceModifiers struct {
Version string `yaml:"version"`
ResourceModifierRules []ResourceModifierRule `yaml:"resourceModifierRules"`
}
func GetResourceModifiersFromConfig(cm *v1.ConfigMap) (*ResourceModifiers, error) {
if cm == nil {
return nil, fmt.Errorf("could not parse config from nil configmap")
}
if len(cm.Data) != 1 {
return nil, fmt.Errorf("illegal resource modifiers %s/%s configmap", cm.Name, cm.Namespace)
}
var yamlData string
for _, v := range cm.Data {
yamlData = v
}
resModifiers, err := unmarshalResourceModifiers(&yamlData)
if err != nil {
return nil, errors.WithStack(err)
}
return resModifiers, nil
}
func (p *ResourceModifiers) ApplyResourceModifierRules(obj *unstructured.Unstructured, groupResource string, log logrus.FieldLogger) []error {
var errs []error
for _, rule := range p.ResourceModifierRules {
err := rule.Apply(obj, groupResource, log)
if err != nil {
errs = append(errs, err)
}
}
return errs
}
func (r *ResourceModifierRule) Apply(obj *unstructured.Unstructured, groupResource string, log logrus.FieldLogger) error {
namespaceInclusion := collections.NewIncludesExcludes().Includes(r.Conditions.Namespaces...)
if !namespaceInclusion.ShouldInclude(obj.GetNamespace()) {
return nil
}
if !strings.EqualFold(groupResource, r.Conditions.GroupKind) {
return nil
}
if r.Conditions.ResourceNameRegex != "" {
match, err := regexp.MatchString(r.Conditions.ResourceNameRegex, obj.GetName())
if err != nil {
return errors.Errorf("error in matching regex %s", err.Error())
}
if !match {
return nil
}
}
patches, err := r.PatchArrayToByteArray()
if err != nil {
return err
}
log.Infof("Applying resource modifier patch on %s/%s", obj.GetNamespace(), obj.GetName())
err = ApplyPatch(patches, obj, log)
if err != nil {
return err
}
return nil
}
// convert all JsonPatch to string array with the format of jsonpatch.Patch and then convert it to byte array
func (r *ResourceModifierRule) PatchArrayToByteArray() ([]byte, error) {
var patches []string
for _, patch := range r.Patches {
patches = append(patches, patch.ToString())
}
patchesStr := strings.Join(patches, ",\n\t")
return []byte(fmt.Sprintf(`[%s]`, patchesStr)), nil
}
func (p *JSONPatch) ToString() string {
if strings.Contains(p.Value, "\"") {
return fmt.Sprintf(`{"op": "%s", "from": "%s", "path": "%s", "value": %s}`, p.Operation, p.From, p.Path, p.Value)
}
return fmt.Sprintf(`{"op": "%s", "from": "%s", "path": "%s", "value": "%s"}`, p.Operation, p.From, p.Path, p.Value)
}
func ApplyPatch(patch []byte, obj *unstructured.Unstructured, log logrus.FieldLogger) error {
jsonPatch, err := jsonpatch.DecodePatch(patch)
if err != nil {
return fmt.Errorf("error in decoding json patch %s", err.Error())
}
objBytes, err := obj.MarshalJSON()
if err != nil {
return fmt.Errorf("error in marshaling object %s", err.Error())
}
modifiedObjBytes, err := jsonPatch.Apply(objBytes)
if err != nil {
if errors.Is(err, jsonpatch.ErrTestFailed) {
log.Infof("Test operation failed for JSON Patch %s", err.Error())
return nil
}
return fmt.Errorf("error in applying JSON Patch %s", err.Error())
}
err = obj.UnmarshalJSON(modifiedObjBytes)
if err != nil {
return fmt.Errorf("error in unmarshalling modified object %s", err.Error())
}
return nil
}
func unmarshalResourceModifiers(yamlData *string) (*ResourceModifiers, error) {
resModifiers := &ResourceModifiers{}
err := decodeStruct(strings.NewReader(*yamlData), resModifiers)
if err != nil {
return nil, fmt.Errorf("failed to decode yaml data into resource modifiers %v", err)
}
return resModifiers, nil
}
// decodeStruct restrict validate the keys in decoded mappings to exist as fields in the struct being decoded into
func decodeStruct(r io.Reader, s interface{}) error {
dec := yaml.NewDecoder(r)
dec.KnownFields(true)
return dec.Decode(s)
}
@@ -0,0 +1,677 @@
package resourcemodifiers
import (
"reflect"
"testing"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
func TestGetResourceModifiersFromConfig(t *testing.T) {
cm1 := &v1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-configmap",
Namespace: "test-namespace",
},
Data: map[string]string{
"sub.yml": "version: v1\nresourceModifierRules:\n- conditions:\n groupKind: persistentvolumeclaims\n resourceNameRegex: \".*\"\n namespaces:\n - bar\n - foo\n patches:\n - operation: replace\n path: \"/spec/storageClassName\"\n value: \"premium\"\n - operation: remove\n path: \"/metadata/labels/test\"\n\n\n",
},
}
rules1 := &ResourceModifiers{
Version: "v1",
ResourceModifierRules: []ResourceModifierRule{
{
Conditions: Conditions{
GroupKind: "persistentvolumeclaims",
ResourceNameRegex: ".*",
Namespaces: []string{"bar", "foo"},
},
Patches: []JSONPatch{
{
Operation: "replace",
Path: "/spec/storageClassName",
Value: "premium",
},
{
Operation: "remove",
Path: "/metadata/labels/test",
},
},
},
},
}
cm2 := &v1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-configmap",
Namespace: "test-namespace",
},
Data: map[string]string{
"sub.yml": "version: v1\nresourceModifierRules:\n- conditions:\n groupKind: deployments.apps\n resourceNameRegex: \"^test-.*$\"\n namespaces:\n - bar\n - foo\n patches:\n - operation: add\n path: \"/spec/template/spec/containers/0\"\n value: \"{\\\"name\\\": \\\"nginx\\\", \\\"image\\\": \\\"nginx:1.14.2\\\", \\\"ports\\\": [{\\\"containerPort\\\": 80}]}\"\n - operation: copy\n from: \"/spec/template/spec/containers/0\"\n path: \"/spec/template/spec/containers/1\"\n\n\n",
},
}
rules2 := &ResourceModifiers{
Version: "v1",
ResourceModifierRules: []ResourceModifierRule{
{
Conditions: Conditions{
GroupKind: "deployments.apps",
ResourceNameRegex: "^test-.*$",
Namespaces: []string{"bar", "foo"},
},
Patches: []JSONPatch{
{
Operation: "add",
Path: "/spec/template/spec/containers/0",
Value: `{"name": "nginx", "image": "nginx:1.14.2", "ports": [{"containerPort": 80}]}`,
},
{
Operation: "copy",
From: "/spec/template/spec/containers/0",
Path: "/spec/template/spec/containers/1",
},
},
},
},
}
cm3 := &v1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-configmap",
Namespace: "test-namespace",
},
Data: map[string]string{
"sub.yml": "version1: v1\nresourceModifierRules:\n- conditions:\n groupKind: deployments.apps\n resourceNameRegex: \"^test-.*$\"\n namespaces:\n - bar\n - foo\n patches:\n - operation: add\n path: \"/spec/template/spec/containers/0\"\n value: \"{\\\"name\\\": \\\"nginx\\\", \\\"image\\\": \\\"nginx:1.14.2\\\", \\\"ports\\\": [{\\\"containerPort\\\": 80}]}\"\n - operation: copy\n from: \"/spec/template/spec/containers/0\"\n path: \"/spec/template/spec/containers/1\"\n\n\n",
},
}
type args struct {
cm *v1.ConfigMap
}
tests := []struct {
name string
args args
want *ResourceModifiers
wantErr bool
}{
{
name: "test 1",
args: args{
cm: cm1,
},
want: rules1,
wantErr: false,
},
{
name: "complex payload in add and copy operator",
args: args{
cm: cm2,
},
want: rules2,
wantErr: false,
},
{
name: "invalid payload version1",
args: args{
cm: cm3,
},
want: nil,
wantErr: true,
},
{
name: "nil configmap",
args: args{
cm: nil,
},
want: nil,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := GetResourceModifiersFromConfig(tt.args.cm)
if (err != nil) != tt.wantErr {
t.Errorf("GetResourceModifiersFromConfig() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("GetResourceModifiersFromConfig() = %v, want %v", got, tt.want)
}
})
}
}
func TestResourceModifiers_ApplyResourceModifierRules(t *testing.T) {
pvcStandardSc := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "PersistentVolumeClaim",
"metadata": map[string]interface{}{
"name": "test-pvc",
"namespace": "foo",
},
"spec": map[string]interface{}{
"storageClassName": "standard",
},
},
}
pvcPremiumSc := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "PersistentVolumeClaim",
"metadata": map[string]interface{}{
"name": "test-pvc",
"namespace": "foo",
},
"spec": map[string]interface{}{
"storageClassName": "premium",
},
},
}
deployNginxOneReplica := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "apps/v1",
"kind": "Deployment",
"metadata": map[string]interface{}{
"name": "test-deployment",
"namespace": "foo",
},
"spec": map[string]interface{}{
"replicas": "1",
"template": map[string]interface{}{
"metadata": map[string]interface{}{
"labels": map[string]interface{}{
"app": "nginx",
},
},
"spec": map[string]interface{}{
"containers": []interface{}{
map[string]interface{}{
"name": "nginx",
"image": "nginx:latest",
},
},
},
},
},
},
}
deployNginxTwoReplica := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "apps/v1",
"kind": "Deployment",
"metadata": map[string]interface{}{
"name": "test-deployment",
"namespace": "foo",
},
"spec": map[string]interface{}{
"replicas": "2",
"template": map[string]interface{}{
"metadata": map[string]interface{}{
"labels": map[string]interface{}{
"app": "nginx",
},
},
"spec": map[string]interface{}{
"containers": []interface{}{
map[string]interface{}{
"name": "nginx",
"image": "nginx:latest",
},
},
},
},
},
},
}
deployNginxMysql := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "apps/v1",
"kind": "Deployment",
"metadata": map[string]interface{}{
"name": "test-deployment",
"namespace": "foo",
},
"spec": map[string]interface{}{
"replicas": "1",
"template": map[string]interface{}{
"metadata": map[string]interface{}{
"labels": map[string]interface{}{
"app": "nginx",
},
},
"spec": map[string]interface{}{
"containers": []interface{}{
map[string]interface{}{
"name": "nginx",
"image": "nginx:latest",
},
map[string]interface{}{
"name": "mysql",
"image": "mysql:latest",
},
},
},
},
},
},
}
type fields struct {
Version string
ResourceModifierRules []ResourceModifierRule
}
type args struct {
obj *unstructured.Unstructured
groupResource string
}
tests := []struct {
name string
fields fields
args args
wantErr bool
wantObj *unstructured.Unstructured
}{
{
name: "Invalid Regex throws error",
fields: fields{
Version: "v1",
ResourceModifierRules: []ResourceModifierRule{
{
Conditions: Conditions{
GroupKind: "persistentvolumeclaims",
ResourceNameRegex: "[a-z",
Namespaces: []string{"foo"},
},
Patches: []JSONPatch{
{
Operation: "test",
Path: "/spec/storageClassName",
Value: "standard",
},
{
Operation: "replace",
Path: "/spec/storageClassName",
Value: "premium",
},
},
},
},
},
args: args{
obj: pvcStandardSc.DeepCopy(),
groupResource: "persistentvolumeclaims",
},
wantErr: true,
wantObj: pvcStandardSc.DeepCopy(),
},
{
name: "pvc with standard storage class should be patched to premium",
fields: fields{
Version: "v1",
ResourceModifierRules: []ResourceModifierRule{
{
Conditions: Conditions{
GroupKind: "persistentvolumeclaims",
ResourceNameRegex: ".*",
Namespaces: []string{"foo"},
},
Patches: []JSONPatch{
{
Operation: "test",
Path: "/spec/storageClassName",
Value: "standard",
},
{
Operation: "replace",
Path: "/spec/storageClassName",
Value: "premium",
},
},
},
},
},
args: args{
obj: pvcStandardSc.DeepCopy(),
groupResource: "persistentvolumeclaims",
},
wantErr: false,
wantObj: pvcPremiumSc.DeepCopy(),
},
{
name: "nginx deployment: 1 -> 2 replicas",
fields: fields{
Version: "v1",
ResourceModifierRules: []ResourceModifierRule{
{
Conditions: Conditions{
GroupKind: "deployments.apps",
ResourceNameRegex: "^test-.*$",
Namespaces: []string{"foo"},
},
Patches: []JSONPatch{
{
Operation: "test",
Path: "/spec/replicas",
Value: "1",
},
{
Operation: "replace",
Path: "/spec/replicas",
Value: "2",
},
},
},
},
},
args: args{
obj: deployNginxOneReplica.DeepCopy(),
groupResource: "deployments.apps",
},
wantErr: false,
wantObj: deployNginxTwoReplica.DeepCopy(),
},
{
name: "nginx deployment: test operator fails, skips substitution, no error",
fields: fields{
Version: "v1",
ResourceModifierRules: []ResourceModifierRule{
{
Conditions: Conditions{
GroupKind: "deployments.apps",
ResourceNameRegex: "^test-.*$",
Namespaces: []string{"foo"},
},
Patches: []JSONPatch{
{
Operation: "test",
Path: "/spec/replicas",
Value: "5",
},
{
Operation: "replace",
Path: "/spec/replicas",
Value: "2",
},
},
},
},
},
args: args{
obj: deployNginxOneReplica.DeepCopy(),
groupResource: "deployments.apps",
},
wantErr: false,
wantObj: deployNginxOneReplica.DeepCopy(),
},
{
name: "nginx deployment: Empty Resource Regex",
fields: fields{
Version: "v1",
ResourceModifierRules: []ResourceModifierRule{
{
Conditions: Conditions{
GroupKind: "deployments.apps",
Namespaces: []string{"foo"},
},
Patches: []JSONPatch{
{
Operation: "test",
Path: "/spec/replicas",
Value: "1",
},
{
Operation: "replace",
Path: "/spec/replicas",
Value: "2",
},
},
},
},
},
args: args{
obj: deployNginxOneReplica.DeepCopy(),
groupResource: "deployments.apps",
},
wantErr: false,
wantObj: deployNginxTwoReplica.DeepCopy(),
},
{
name: "nginx deployment: Empty Resource Regex and namespaces list",
fields: fields{
Version: "v1",
ResourceModifierRules: []ResourceModifierRule{
{
Conditions: Conditions{
GroupKind: "deployments.apps",
},
Patches: []JSONPatch{
{
Operation: "test",
Path: "/spec/replicas",
Value: "1",
},
{
Operation: "replace",
Path: "/spec/replicas",
Value: "2",
},
},
},
},
},
args: args{
obj: deployNginxOneReplica.DeepCopy(),
groupResource: "deployments.apps",
},
wantErr: false,
wantObj: deployNginxTwoReplica.DeepCopy(),
},
{
name: "nginx deployment: namespace doesn't match",
fields: fields{
Version: "v1",
ResourceModifierRules: []ResourceModifierRule{
{
Conditions: Conditions{
GroupKind: "deployments.apps",
ResourceNameRegex: ".*",
Namespaces: []string{"bar"},
},
Patches: []JSONPatch{
{
Operation: "test",
Path: "/spec/replicas",
Value: "1",
},
{
Operation: "replace",
Path: "/spec/replicas",
Value: "2",
},
},
},
},
},
args: args{
obj: deployNginxOneReplica.DeepCopy(),
groupResource: "deployments.apps",
},
wantErr: false,
wantObj: deployNginxOneReplica.DeepCopy(),
},
{
name: "add container mysql to deployment",
fields: fields{
Version: "v1",
ResourceModifierRules: []ResourceModifierRule{
{
Conditions: Conditions{
GroupKind: "deployments.apps",
ResourceNameRegex: "^test-.*$",
Namespaces: []string{"foo"},
},
Patches: []JSONPatch{
{
Operation: "add",
Path: "/spec/template/spec/containers/1",
Value: `{"name": "mysql", "image": "mysql:latest"}`,
},
},
},
},
},
args: args{
obj: deployNginxOneReplica,
groupResource: "deployments.apps",
},
wantErr: false,
wantObj: deployNginxMysql,
},
{
name: "Copy container 0 to container 1 and then modify container 1",
fields: fields{
Version: "v1",
ResourceModifierRules: []ResourceModifierRule{
{
Conditions: Conditions{
GroupKind: "deployments.apps",
ResourceNameRegex: "^test-.*$",
Namespaces: []string{"foo"},
},
Patches: []JSONPatch{
{
Operation: "copy",
From: "/spec/template/spec/containers/0",
Path: "/spec/template/spec/containers/1",
},
{
Operation: "test",
Path: "/spec/template/spec/containers/1/image",
Value: "nginx:latest",
},
{
Operation: "replace",
Path: "/spec/template/spec/containers/1/name",
Value: "mysql",
},
{
Operation: "replace",
Path: "/spec/template/spec/containers/1/image",
Value: "mysql:latest",
},
},
},
},
},
args: args{
obj: deployNginxOneReplica.DeepCopy(),
groupResource: "deployments.apps",
},
wantErr: false,
wantObj: deployNginxMysql.DeepCopy(),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := &ResourceModifiers{
Version: tt.fields.Version,
ResourceModifierRules: tt.fields.ResourceModifierRules,
}
got := p.ApplyResourceModifierRules(tt.args.obj, tt.args.groupResource, logrus.New())
assert.Equal(t, tt.wantErr, len(got) > 0)
assert.Equal(t, *tt.wantObj, *tt.args.obj)
})
}
}
func TestJSONPatch_ToString(t *testing.T) {
type fields struct {
Operation string
From string
Path string
Value string
}
tests := []struct {
name string
fields fields
want string
}{
{
name: "test",
fields: fields{
Operation: "test",
Path: "/spec/replicas",
Value: "1",
},
want: `{"op": "test", "from": "", "path": "/spec/replicas", "value": "1"}`,
},
{
name: "replace",
fields: fields{
Operation: "replace",
Path: "/spec/replicas",
Value: "2",
},
want: `{"op": "replace", "from": "", "path": "/spec/replicas", "value": "2"}`,
},
{
name: "add complex interfaces",
fields: fields{
Operation: "add",
Path: "/spec/template/spec/containers/0",
Value: `{"name": "nginx", "image": "nginx:1.14.2", "ports": [{"containerPort": 80}]}`,
},
want: `{"op": "add", "from": "", "path": "/spec/template/spec/containers/0", "value": {"name": "nginx", "image": "nginx:1.14.2", "ports": [{"containerPort": 80}]}}`,
},
{
name: "remove",
fields: fields{
Operation: "remove",
Path: "/spec/template/spec/containers/0",
},
want: `{"op": "remove", "from": "", "path": "/spec/template/spec/containers/0", "value": ""}`,
},
{
name: "move",
fields: fields{
Operation: "move",
From: "/spec/template/spec/containers/0",
Path: "/spec/template/spec/containers/1",
},
want: `{"op": "move", "from": "/spec/template/spec/containers/0", "path": "/spec/template/spec/containers/1", "value": ""}`,
},
{
name: "copy",
fields: fields{
Operation: "copy",
From: "/spec/template/spec/containers/0",
Path: "/spec/template/spec/containers/1",
},
want: `{"op": "copy", "from": "/spec/template/spec/containers/0", "path": "/spec/template/spec/containers/1", "value": ""}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := &JSONPatch{
Operation: tt.fields.Operation,
From: tt.fields.From,
Path: tt.fields.Path,
Value: tt.fields.Value,
}
if got := p.ToString(); got != tt.want {
t.Errorf("JSONPatch.ToString() = %v, want %v", got, tt.want)
}
})
}
}
@@ -0,0 +1,55 @@
package resourcemodifiers
import (
"strings"
"fmt"
)
func (r *ResourceModifierRule) Validate() error {
if err := r.Conditions.Validate(); err != nil {
return err
}
for _, patch := range r.Patches {
if err := patch.Validate(); err != nil {
return err
}
}
return nil
}
func (p *ResourceModifiers) Validate() error {
if !strings.EqualFold(p.Version, ResourceModifierSupportedVersionV1) {
return fmt.Errorf("unsupported resource modifier version %s", p.Version)
}
if len(p.ResourceModifierRules) == 0 {
return fmt.Errorf("resource modifier rules cannot be empty")
}
for _, rule := range p.ResourceModifierRules {
if err := rule.Validate(); err != nil {
return err
}
}
return nil
}
func (p *JSONPatch) Validate() error {
if p.Operation == "" {
return fmt.Errorf("operation cannot be empty")
}
if operation := strings.ToLower(p.Operation); operation != "add" && operation != "remove" && operation != "replace" && operation != "test" && operation != "move" && operation != "copy" {
return fmt.Errorf("unsupported operation %s", p.Operation)
}
if p.Path == "" {
return fmt.Errorf("path cannot be empty")
}
return nil
}
func (c *Conditions) Validate() error {
if c.GroupKind == "" {
return fmt.Errorf("groupkind cannot be empty")
}
return nil
}
@@ -0,0 +1,192 @@
package resourcemodifiers
import (
"testing"
)
func TestResourceModifiers_Validate(t *testing.T) {
type fields struct {
Version string
ResourceModifierRules []ResourceModifierRule
}
tests := []struct {
name string
fields fields
wantErr bool
}{
{
name: "correct version, non 0 length ResourceModifierRules",
fields: fields{
Version: "v1",
ResourceModifierRules: []ResourceModifierRule{
{
Conditions: Conditions{
GroupKind: "persistentvolumeclaims",
ResourceNameRegex: ".*",
Namespaces: []string{"bar", "foo"},
},
Patches: []JSONPatch{
{
Operation: "replace",
Path: "/spec/storageClassName",
Value: "premium",
},
},
},
},
},
wantErr: false,
},
{
name: "incorrect version, non 0 length ResourceModifierRules",
fields: fields{
Version: "v2",
ResourceModifierRules: []ResourceModifierRule{
{
Conditions: Conditions{
GroupKind: "persistentvolumeclaims",
ResourceNameRegex: ".*",
Namespaces: []string{"bar", "foo"},
},
Patches: []JSONPatch{
{
Operation: "replace",
Path: "/spec/storageClassName",
Value: "premium",
},
},
},
},
},
wantErr: true,
},
{
name: "correct version, 0 length ResourceModifierRules",
fields: fields{
Version: "v1",
ResourceModifierRules: []ResourceModifierRule{},
},
wantErr: true,
},
{
name: "patch has invalid operation",
fields: fields{
Version: "v1",
ResourceModifierRules: []ResourceModifierRule{
{
Conditions: Conditions{
GroupKind: "persistentvolumeclaims",
ResourceNameRegex: ".*",
Namespaces: []string{"bar", "foo"},
},
Patches: []JSONPatch{
{
Operation: "invalid",
Path: "/spec/storageClassName",
Value: "premium",
},
},
},
},
},
wantErr: true,
},
{
name: "Condition has empty GroupKind",
fields: fields{
Version: "v1",
ResourceModifierRules: []ResourceModifierRule{
{
Conditions: Conditions{
GroupKind: "",
ResourceNameRegex: ".*",
Namespaces: []string{"bar", "foo"},
},
Patches: []JSONPatch{
{
Operation: "invalid",
Path: "/spec/storageClassName",
Value: "premium",
},
},
},
},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := &ResourceModifiers{
Version: tt.fields.Version,
ResourceModifierRules: tt.fields.ResourceModifierRules,
}
if err := p.Validate(); (err != nil) != tt.wantErr {
t.Errorf("ResourceModifiers.Validate() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestJsonPatch_Validate(t *testing.T) {
type fields struct {
Operation string
Path string
Value string
}
tests := []struct {
name string
fields fields
wantErr bool
}{
{
name: "not empty operation, path, and new value, valid scenario",
fields: fields{
Operation: "replace",
Path: "/spec/storageClassName",
Value: "premium",
},
wantErr: false,
},
{
name: "empty operation throws error",
fields: fields{
Operation: "",
Path: "/spec/storageClassName",
Value: "premium",
},
wantErr: true,
},
{
name: "empty path throws error",
fields: fields{
Operation: "replace",
Path: "",
Value: "premium",
},
wantErr: true,
},
{
name: "invalid operation throws error",
fields: fields{
Operation: "invalid",
Path: "/spec/storageClassName",
Value: "premium",
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := &JSONPatch{
Operation: tt.fields.Operation,
Path: tt.fields.Path,
Value: tt.fields.Value,
}
if err := p.Validate(); (err != nil) != tt.wantErr {
t.Errorf("JsonPatch.Validate() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
+6
View File
@@ -17,6 +17,7 @@ limitations under the License.
package v1
import (
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
)
@@ -117,6 +118,11 @@ type RestoreSpec struct {
// The default value is 1 hour.
// +optional
ItemOperationTimeout metav1.Duration `json:"itemOperationTimeout,omitempty"`
// ResourceModifier specifies the reference to JSON resource patches that should be applied to resources before restoration.
// +optional
// +nullable
ResourceModifier *v1.TypedLocalObjectReference `json:"resourceModifier,omitempty"`
}
// RestoreHooks contains custom behaviors that should be executed during or post restore.
@@ -1317,6 +1317,11 @@ func (in *RestoreSpec) DeepCopyInto(out *RestoreSpec) {
}
in.Hooks.DeepCopyInto(&out.Hooks)
out.ItemOperationTimeout = in.ItemOperationTimeout
if in.ResourceModifier != nil {
in, out := &in.ResourceModifier, &out.ResourceModifier
*out = new(corev1.TypedLocalObjectReference)
(*in).DeepCopyInto(*out)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RestoreSpec.
+35 -20
View File
@@ -25,11 +25,13 @@ import (
"github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/client-go/tools/cache"
kbclient "sigs.k8s.io/controller-runtime/pkg/client"
"github.com/vmware-tanzu/velero/internal/resourcemodifiers"
api "github.com/vmware-tanzu/velero/pkg/apis/velero/v1"
"github.com/vmware-tanzu/velero/pkg/client"
"github.com/vmware-tanzu/velero/pkg/cmd"
@@ -75,26 +77,27 @@ func NewCreateCommand(f client.Factory, use string) *cobra.Command {
}
type CreateOptions struct {
BackupName string
ScheduleName string
RestoreName string
RestoreVolumes flag.OptionalBool
PreserveNodePorts flag.OptionalBool
Labels flag.Map
IncludeNamespaces flag.StringArray
ExcludeNamespaces flag.StringArray
ExistingResourcePolicy string
IncludeResources flag.StringArray
ExcludeResources flag.StringArray
StatusIncludeResources flag.StringArray
StatusExcludeResources flag.StringArray
NamespaceMappings flag.Map
Selector flag.LabelSelector
IncludeClusterResources flag.OptionalBool
Wait bool
AllowPartiallyFailed flag.OptionalBool
ItemOperationTimeout time.Duration
client kbclient.WithWatch
BackupName string
ScheduleName string
RestoreName string
RestoreVolumes flag.OptionalBool
PreserveNodePorts flag.OptionalBool
Labels flag.Map
IncludeNamespaces flag.StringArray
ExcludeNamespaces flag.StringArray
ExistingResourcePolicy string
IncludeResources flag.StringArray
ExcludeResources flag.StringArray
StatusIncludeResources flag.StringArray
StatusExcludeResources flag.StringArray
NamespaceMappings flag.Map
Selector flag.LabelSelector
IncludeClusterResources flag.OptionalBool
Wait bool
AllowPartiallyFailed flag.OptionalBool
ItemOperationTimeout time.Duration
ResourceModifierConfigMap string
client kbclient.WithWatch
}
func NewCreateOptions() *CreateOptions {
@@ -139,6 +142,8 @@ func (o *CreateOptions) BindFlags(flags *pflag.FlagSet) {
f.NoOptDefVal = cmd.TRUE
flags.BoolVarP(&o.Wait, "wait", "w", o.Wait, "Wait for the operation to complete.")
flags.StringVar(&o.ResourceModifierConfigMap, "resource-modifier-configmap", "", "Reference to the resource modifier configmap that restore will use")
}
func (o *CreateOptions) Complete(args []string, f client.Factory) error {
@@ -272,6 +277,15 @@ func (o *CreateOptions) Run(c *cobra.Command, f client.Factory) error {
}
}
var resModifiers *corev1.TypedLocalObjectReference = nil
if o.ResourceModifierConfigMap != "" {
resModifiers = &corev1.TypedLocalObjectReference{
Kind: resourcemodifiers.ConfigmapRefType,
Name: o.ResourceModifierConfigMap,
}
}
restore := &api.Restore{
ObjectMeta: metav1.ObjectMeta{
Namespace: f.Namespace(),
@@ -291,6 +305,7 @@ func (o *CreateOptions) Run(c *cobra.Command, f client.Factory) error {
RestorePVs: o.RestoreVolumes.Value,
PreserveNodePorts: o.PreserveNodePorts.Value,
IncludeClusterResources: o.IncludeClusterResources.Value,
ResourceModifier: resModifiers,
ItemOperationTimeout: metav1.Duration{
Duration: o.ItemOperationTimeout,
},
+38 -15
View File
@@ -29,6 +29,7 @@ import (
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
corev1api "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/types"
@@ -39,6 +40,7 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client"
"github.com/vmware-tanzu/velero/internal/hook"
"github.com/vmware-tanzu/velero/internal/resourcemodifiers"
api "github.com/vmware-tanzu/velero/pkg/apis/velero/v1"
"github.com/vmware-tanzu/velero/pkg/itemoperation"
"github.com/vmware-tanzu/velero/pkg/label"
@@ -163,7 +165,7 @@ func (r *restoreReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct
original := restore.DeepCopy()
// Validate the restore and fetch the backup
info := r.validateAndComplete(restore)
info, resourceModifiers := r.validateAndComplete(restore)
// Register attempts after validation so we don't have to fetch the backup multiple times
backupScheduleName := restore.Spec.ScheduleName
@@ -197,7 +199,7 @@ func (r *restoreReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct
return ctrl.Result{}, nil
}
if err := r.runValidatedRestore(restore, info); err != nil {
if err := r.runValidatedRestore(restore, info, resourceModifiers); err != nil {
log.WithError(err).Debug("Restore failed")
restore.Status.Phase = api.RestorePhaseFailed
restore.Status.FailureReason = err.Error()
@@ -241,7 +243,7 @@ func (r *restoreReconciler) SetupWithManager(mgr ctrl.Manager) error {
Complete(r)
}
func (r *restoreReconciler) validateAndComplete(restore *api.Restore) backupInfo {
func (r *restoreReconciler) validateAndComplete(restore *api.Restore) (backupInfo, *resourcemodifiers.ResourceModifiers) {
// add non-restorable resources to restore's excluded resources
excludedResources := sets.NewString(restore.Spec.ExcludedResources...)
for _, nonrestorable := range nonRestorableResources {
@@ -276,7 +278,7 @@ func (r *restoreReconciler) validateAndComplete(restore *api.Restore) backupInfo
// validate that exactly one of BackupName and ScheduleName have been specified
if !backupXorScheduleProvided(restore) {
restore.Status.ValidationErrors = append(restore.Status.ValidationErrors, "Either a backup or schedule must be specified as a source for the restore, but not both")
return backupInfo{}
return backupInfo{}, nil
}
// validate Restore Init Hook's InitContainers
@@ -307,7 +309,7 @@ func (r *restoreReconciler) validateAndComplete(restore *api.Restore) backupInfo
backupList := &api.BackupList{}
if err := r.kbClient.List(context.Background(), backupList, &client.ListOptions{LabelSelector: selector}); err != nil {
restore.Status.ValidationErrors = append(restore.Status.ValidationErrors, "Unable to list backups for schedule")
return backupInfo{}
return backupInfo{}, nil
}
if len(backupList.Items) == 0 {
restore.Status.ValidationErrors = append(restore.Status.ValidationErrors, "No backups found for schedule")
@@ -317,14 +319,14 @@ func (r *restoreReconciler) validateAndComplete(restore *api.Restore) backupInfo
restore.Spec.BackupName = backup.Name
} else {
restore.Status.ValidationErrors = append(restore.Status.ValidationErrors, "No completed backups found for schedule")
return backupInfo{}
return backupInfo{}, nil
}
}
info, err := r.fetchBackupInfo(restore.Spec.BackupName)
if err != nil {
restore.Status.ValidationErrors = append(restore.Status.ValidationErrors, fmt.Sprintf("Error retrieving backup: %v", err))
return backupInfo{}
return backupInfo{}, nil
}
// Fill in the ScheduleName so it's easier to consume for metrics.
@@ -332,7 +334,26 @@ func (r *restoreReconciler) validateAndComplete(restore *api.Restore) backupInfo
restore.Spec.ScheduleName = info.backup.GetLabels()[api.ScheduleNameLabel]
}
return info
var resourceModifiers *resourcemodifiers.ResourceModifiers = nil
if restore.Spec.ResourceModifier != nil && restore.Spec.ResourceModifier.Kind == resourcemodifiers.ConfigmapRefType {
ResourceModifierConfigMap := &corev1api.ConfigMap{}
err := r.kbClient.Get(context.Background(), client.ObjectKey{Namespace: restore.Namespace, Name: restore.Spec.ResourceModifier.Name}, ResourceModifierConfigMap)
if err != nil {
restore.Status.ValidationErrors = append(restore.Status.ValidationErrors, fmt.Sprintf("failed to get resource modifiers configmap %s/%s", restore.Namespace, restore.Spec.ResourceModifier.Name))
return backupInfo{}, nil
}
resourceModifiers, err = resourcemodifiers.GetResourceModifiersFromConfig(ResourceModifierConfigMap)
if err != nil {
restore.Status.ValidationErrors = append(restore.Status.ValidationErrors, errors.Wrapf(err, fmt.Sprintf("Error in parsing resource modifiers provided in configmap %s/%s", restore.Namespace, restore.Spec.ResourceModifier.Name)).Error())
return backupInfo{}, nil
} else if err = resourceModifiers.Validate(); err != nil {
restore.Status.ValidationErrors = append(restore.Status.ValidationErrors, errors.Wrapf(err, fmt.Sprintf("Validation error in resource modifiers provided in configmap %s/%s", restore.Namespace, restore.Spec.ResourceModifier.Name)).Error())
return backupInfo{}, nil
}
r.logger.Infof("Retrieved Resource modifiers provided in configmap %s/%s", restore.Namespace, restore.Spec.ResourceModifier.Name)
}
return info, resourceModifiers
}
// backupXorScheduleProvided returns true if exactly one of BackupName and
@@ -405,7 +426,7 @@ func fetchBackupInfoInternal(kbClient client.Client, namespace, backupName strin
// The log and results files are uploaded to backup storage. Any error returned from this function
// means that the restore failed. This function updates the restore API object with warning and error
// counts, but *does not* update its phase or patch it via the API.
func (r *restoreReconciler) runValidatedRestore(restore *api.Restore, info backupInfo) error {
func (r *restoreReconciler) runValidatedRestore(restore *api.Restore, info backupInfo, resourceModifiers *resourcemodifiers.ResourceModifiers) error {
// instantiate the per-restore logger that will output both to a temp file
// (for upload to object storage) and to stdout.
restoreLog, err := logging.NewTempFileLogger(r.restoreLogLevel, r.logFormat, nil, logrus.Fields{"restore": kubeutil.NamespaceAndName(restore)})
@@ -458,13 +479,15 @@ func (r *restoreReconciler) runValidatedRestore(restore *api.Restore, info backu
for i := range podVolumeBackupList.Items {
podVolumeBackups = append(podVolumeBackups, &podVolumeBackupList.Items[i])
}
restoreReq := &pkgrestore.Request{
Log: restoreLog,
Restore: restore,
Backup: info.backup,
PodVolumeBackups: podVolumeBackups,
VolumeSnapshots: volumeSnapshots,
BackupReader: backupFile,
Log: restoreLog,
Restore: restore,
Backup: info.backup,
PodVolumeBackups: podVolumeBackups,
VolumeSnapshots: volumeSnapshots,
BackupReader: backupFile,
ResourceModifiers: resourceModifiers,
}
restoreWarnings, restoreErrors := r.restorer.RestoreWithResolvers(restoreReq, actionsResolver, pluginManager)
+142
View File
@@ -34,6 +34,9 @@ import (
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
corev1 "k8s.io/api/core/v1"
"github.com/vmware-tanzu/velero/internal/resourcemodifiers"
velerov1api "github.com/vmware-tanzu/velero/pkg/apis/velero/v1"
"github.com/vmware-tanzu/velero/pkg/builder"
"github.com/vmware-tanzu/velero/pkg/metrics"
@@ -659,6 +662,145 @@ func TestValidateAndCompleteWhenScheduleNameSpecified(t *testing.T) {
assert.Equal(t, "foo", restore.Spec.BackupName)
}
func TestValidateAndCompleteWithResourceModifierSpecified(t *testing.T) {
formatFlag := logging.FormatText
var (
logger = velerotest.NewLogger()
pluginManager = &pluginmocks.Manager{}
fakeClient = velerotest.NewFakeControllerRuntimeClient(t)
backupStore = &persistencemocks.BackupStore{}
)
r := NewRestoreReconciler(
context.Background(),
velerov1api.DefaultNamespace,
nil,
fakeClient,
logger,
logrus.DebugLevel,
func(logrus.FieldLogger) clientmgmt.Manager { return pluginManager },
NewFakeSingleObjectBackupStoreGetter(backupStore),
metrics.NewServerMetrics(),
formatFlag,
60*time.Minute,
)
restore := &velerov1api.Restore{
ObjectMeta: metav1.ObjectMeta{
Namespace: velerov1api.DefaultNamespace,
Name: "restore-1",
},
Spec: velerov1api.RestoreSpec{
BackupName: "backup-1",
ResourceModifier: &corev1.TypedLocalObjectReference{
Kind: resourcemodifiers.ConfigmapRefType,
Name: "test-configmap",
},
},
}
location := builder.ForBackupStorageLocation("velero", "default").Provider("myCloud").Bucket("bucket").Result()
require.NoError(t, r.kbClient.Create(context.Background(), location))
require.NoError(t, r.kbClient.Create(
context.Background(),
defaultBackup().
ObjectMeta(
builder.WithName("backup-1"),
).StorageLocation("default").
Phase(velerov1api.BackupPhaseCompleted).
Result(),
))
r.validateAndComplete(restore)
assert.Contains(t, restore.Status.ValidationErrors[0], "failed to get resource modifiers configmap")
restore1 := &velerov1api.Restore{
ObjectMeta: metav1.ObjectMeta{
Namespace: velerov1api.DefaultNamespace,
Name: "restore-1",
},
Spec: velerov1api.RestoreSpec{
BackupName: "backup-1",
ResourceModifier: &corev1.TypedLocalObjectReference{
Kind: resourcemodifiers.ConfigmapRefType,
Name: "test-configmap",
},
},
}
cm1 := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-configmap",
Namespace: velerov1api.DefaultNamespace,
},
Data: map[string]string{
"sub.yml": "version: v1\nresourceModifierRules:\n- conditions:\n groupKind: persistentvolumeclaims\n resourceNameRegex: \".*\"\n namespaces:\n - bar\n - foo\n patches:\n - operation: replace\n path: \"/spec/storageClassName\"\n value: \"premium\"\n - operation: remove\n path: \"/metadata/labels/test\"\n\n\n",
},
}
require.NoError(t, r.kbClient.Create(context.Background(), cm1))
r.validateAndComplete(restore1)
assert.Nil(t, restore1.Status.ValidationErrors)
restore2 := &velerov1api.Restore{
ObjectMeta: metav1.ObjectMeta{
Namespace: velerov1api.DefaultNamespace,
Name: "restore-1",
},
Spec: velerov1api.RestoreSpec{
BackupName: "backup-1",
ResourceModifier: &corev1.TypedLocalObjectReference{
Kind: resourcemodifiers.ConfigmapRefType,
Name: "test-configmap-invalid",
},
},
}
invalidVersionCm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-configmap-invalid",
Namespace: velerov1api.DefaultNamespace,
},
Data: map[string]string{
"sub.yml": "version1: v1\nresourceModifierRules:\n- conditions:\n groupKind: persistentvolumeclaims\n resourceNameRegex: \".*\"\n namespaces:\n - bar\n - foo\n patches:\n - operation: replace\n path: \"/spec/storageClassName\"\n value: \"premium\"\n - operation: remove\n path: \"/metadata/labels/test\"\n\n\n",
},
}
require.NoError(t, r.kbClient.Create(context.Background(), invalidVersionCm))
r.validateAndComplete(restore2)
assert.Contains(t, restore2.Status.ValidationErrors[0], "Error in parsing resource modifiers provided in configmap")
restore3 := &velerov1api.Restore{
ObjectMeta: metav1.ObjectMeta{
Namespace: velerov1api.DefaultNamespace,
Name: "restore-1",
},
Spec: velerov1api.RestoreSpec{
BackupName: "backup-1",
ResourceModifier: &corev1.TypedLocalObjectReference{
Kind: resourcemodifiers.ConfigmapRefType,
Name: "test-configmap-invalid-operator",
},
},
}
invalidOperatorCm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-configmap-invalid-operator",
Namespace: velerov1api.DefaultNamespace,
},
Data: map[string]string{
"sub.yml": "version: v1\nresourceModifierRules:\n- conditions:\n groupKind: persistentvolumeclaims\n resourceNameRegex: \".*\"\n namespaces:\n - bar\n - foo\n patches:\n - operation: invalid\n path: \"/spec/storageClassName\"\n value: \"premium\"\n - operation: remove\n path: \"/metadata/labels/test\"\n\n\n",
},
}
require.NoError(t, r.kbClient.Create(context.Background(), invalidOperatorCm))
r.validateAndComplete(restore3)
assert.Contains(t, restore3.Status.ValidationErrors[0], "Validation error in resource modifiers provided in configmap")
}
func TestBackupXorScheduleProvided(t *testing.T) {
r := &velerov1api.Restore{}
assert.False(t, backupXorScheduleProvided(r))
+2
View File
@@ -24,6 +24,7 @@ import (
"github.com/sirupsen/logrus"
"k8s.io/apimachinery/pkg/runtime"
"github.com/vmware-tanzu/velero/internal/resourcemodifiers"
velerov1api "github.com/vmware-tanzu/velero/pkg/apis/velero/v1"
"github.com/vmware-tanzu/velero/pkg/itemoperation"
"github.com/vmware-tanzu/velero/pkg/volume"
@@ -57,6 +58,7 @@ type Request struct {
BackupReader io.Reader
RestoredItems map[itemKey]restoredItemStatus
itemOperationsList *[]*itemoperation.RestoreOperation
ResourceModifiers *resourcemodifiers.ResourceModifiers
}
type restoredItemStatus struct {
+11
View File
@@ -48,6 +48,7 @@ import (
"github.com/vmware-tanzu/velero/internal/credentials"
"github.com/vmware-tanzu/velero/internal/hook"
"github.com/vmware-tanzu/velero/internal/resourcemodifiers"
velerov1api "github.com/vmware-tanzu/velero/pkg/apis/velero/v1"
"github.com/vmware-tanzu/velero/pkg/archive"
"github.com/vmware-tanzu/velero/pkg/client"
@@ -305,6 +306,7 @@ func (kr *kubernetesRestorer) RestoreWithResolvers(
hooksCancelFunc: hooksCancelFunc,
kbClient: kr.kbClient,
itemOperationsList: req.GetItemOperationsList(),
resourceModifiers: req.ResourceModifiers,
}
return restoreCtx.execute()
@@ -350,6 +352,7 @@ type restoreContext struct {
hooksCancelFunc go_context.CancelFunc
kbClient crclient.Client
itemOperationsList *[]*itemoperation.RestoreOperation
resourceModifiers *resourcemodifiers.ResourceModifiers
}
type resourceClientKey struct {
@@ -1355,6 +1358,14 @@ func (ctx *restoreContext) restoreItem(obj *unstructured.Unstructured, groupReso
// and which backup they came from.
addRestoreLabels(obj, ctx.restore.Name, ctx.restore.Spec.BackupName)
if ctx.resourceModifiers != nil {
if errList := ctx.resourceModifiers.ApplyResourceModifierRules(obj, groupResource.String(), ctx.log); errList != nil {
for _, err := range errList {
errs.Add(namespace, err)
}
}
}
ctx.log.Infof("Attempting to restore %s: %v", obj.GroupVersionKind().Kind, name)
createdObj, restoreErr := resourceClient.Create(obj)
if restoreErr == nil {