From c1be25c1ddce5590a933a9a0cda0436e88ebde53 Mon Sep 17 00:00:00 2001 From: Daniel Fan Date: Mon, 30 Sep 2024 00:17:29 -0400 Subject: [PATCH] Add optionalFields into OperandConfig, to conditionally prune fields based on matchExpressions (#1085) Signed-off-by: Daniel Fan --- api/v1alpha1/operandconfig_types.go | 75 ++++++ ...fecycle-manager.clusterserviceversion.yaml | 8 +- .../operator.ibm.com_operandconfigs.yaml | 100 +++++++ .../operator.ibm.com_operandconfigs.yaml | 100 +++++++ config/rbac/role.yaml | 6 + .../operandrequest_controller.go | 1 + .../operandrequest/reconcile_operand.go | 104 +++++++- .../operandrequest/reconcile_operand_test.go | 169 ++++++++++++ controllers/util/util.go | 89 +++++++ controllers/util/util_test.go | 248 ++++++++++++++++++ 10 files changed, 889 insertions(+), 11 deletions(-) diff --git a/api/v1alpha1/operandconfig_types.go b/api/v1alpha1/operandconfig_types.go index 4b4e643f..755917f0 100644 --- a/api/v1alpha1/operandconfig_types.go +++ b/api/v1alpha1/operandconfig_types.go @@ -79,6 +79,61 @@ type ConfigResource struct { // OwnerReferences is the list of owner references. // +optional OwnerReferences []OwnerReference `json:"ownerReferences,omitempty"` + // OptionalFields is the list of fields that could be updated additionally. + // +optional + OptionalFields []OptionalField `json:"optionalFields,omitempty"` +} + +// +kubebuilder:pruning:PreserveUnknownFields +// OptionalField defines the optional field for the resource. +type OptionalField struct { + // Path is the json path of the field. + Path string `json:"path"` + // Operation is the operation of the field. + Operation Operation `json:"operation"` + // MatchExpressions is the match expression of the field. + // +optional + MatchExpressions []MatchExpression `json:"matchExpressions,omitempty"` + // ValueFrom is the field value from the object + // +optional + ValueFrom *ValueFrom `json:"valueFrom,omitempty"` +} + +// +kubebuilder:pruning:PreserveUnknownFields +// MatchExpression defines the match expression of the field. +type MatchExpression struct { + // Key is the key of the field. + Key string `json:"key"` + // Operator is the operator of the field. + Operator ExpressionOperator `json:"operator"` + // Values is the values of the field. + // +optional + Values []string `json:"values"` + // ObjectRef is the reference of the object. + // +optional + ObjectRef *ObjectRef `json:"objectRef,omitempty"` +} + +// ObjectRef defines the reference of the object. +type ObjectRef struct { + // APIVersion is the version of the object. + APIVersion string `json:"apiVersion"` + // Kind is the kind of the object. + Kind string `json:"kind"` + // Name is the name of the object. + Name string `json:"name"` + // Namespace is the namespace of the object. + // +optional + Namespace string `json:"namespace"` +} + +// ValueFrom defines the field value from the object. +type ValueFrom struct { + // Path is the json path of the field. + Path string `json:"path"` + // ObjectRef is the reference of the object. + // +optional + ObjectRef *ObjectRef `json:"objectRef,omitempty"` } type OwnerReference struct { @@ -161,6 +216,26 @@ const ( ServiceNone ServicePhase = "" ) +// Operation defines the operation of the field. +type Operation string + +// Operation type. +const ( + OperationAdd Operation = "add" + OperationRemove Operation = "remove" +) + +// Operator defines the operator type. +type ExpressionOperator string + +// Operator type. +const ( + OperatorIn ExpressionOperator = "In" + OperatorNotIn ExpressionOperator = "NotIn" + OperatorExists ExpressionOperator = "Exists" + OperatorDoesNotExist ExpressionOperator = "DoesNotExist" +) + // GetService obtains the service definition with the operand name. func (r *OperandConfig) GetService(operandName string) *ConfigService { for _, s := range r.Spec.Services { diff --git a/bundle/manifests/operand-deployment-lifecycle-manager.clusterserviceversion.yaml b/bundle/manifests/operand-deployment-lifecycle-manager.clusterserviceversion.yaml index 929d2e50..8049aca2 100644 --- a/bundle/manifests/operand-deployment-lifecycle-manager.clusterserviceversion.yaml +++ b/bundle/manifests/operand-deployment-lifecycle-manager.clusterserviceversion.yaml @@ -129,7 +129,7 @@ metadata: categories: Developer Tools, Monitoring, Logging & Tracing, Security certified: "false" containerImage: icr.io/cpopen/odlm:latest - createdAt: "2024-08-27T18:18:40Z" + createdAt: "2024-09-28T19:55:35Z" description: The Operand Deployment Lifecycle Manager provides a Kubernetes CRD-based API to manage the lifecycle of operands. nss.operator.ibm.com/managed-operators: ibm-odlm olm.skipRange: '>=1.2.0 <4.3.6' @@ -568,6 +568,12 @@ spec: spec: clusterPermissions: - rules: + - apiGroups: + - apiextensions.k8s.io + resources: + - customresourcedefinitions + verbs: + - get - apiGroups: - operator.ibm.com resources: diff --git a/bundle/manifests/operator.ibm.com_operandconfigs.yaml b/bundle/manifests/operator.ibm.com_operandconfigs.yaml index d0950bb8..3df97861 100644 --- a/bundle/manifests/operator.ibm.com_operandconfigs.yaml +++ b/bundle/manifests/operator.ibm.com_operandconfigs.yaml @@ -109,6 +109,106 @@ spec: namespace: description: Namespace is the namespace of the resource. type: string + optionalFields: + description: OptionalFields is the list of fields that + could be updated additionally. + items: + description: OptionalField defines the optional field + for the resource. + properties: + matchExpressions: + description: MatchExpressions is the match expression + of the field. + items: + description: MatchExpression defines the match + expression of the field. + properties: + key: + description: Key is the key of the field. + type: string + objectRef: + description: ObjectRef is the reference of + the object. + properties: + apiVersion: + description: APIVersion is the version + of the object. + type: string + kind: + description: Kind is the kind of the object. + type: string + name: + description: Name is the name of the object. + type: string + namespace: + description: Namespace is the namespace + of the object. + type: string + required: + - apiVersion + - kind + - name + type: object + operator: + description: Operator is the operator of the + field. + type: string + values: + description: Values is the values of the field. + items: + type: string + type: array + required: + - key + - operator + type: object + x-kubernetes-preserve-unknown-fields: true + type: array + operation: + description: Operation is the operation of the field. + type: string + path: + description: Path is the json path of the field. + type: string + valueFrom: + description: ValueFrom is the field value from the + object + properties: + objectRef: + description: ObjectRef is the reference of the + object. + properties: + apiVersion: + description: APIVersion is the version of + the object. + type: string + kind: + description: Kind is the kind of the object. + type: string + name: + description: Name is the name of the object. + type: string + namespace: + description: Namespace is the namespace + of the object. + type: string + required: + - apiVersion + - kind + - name + type: object + path: + description: Path is the json path of the field. + type: string + required: + - path + type: object + required: + - operation + - path + type: object + x-kubernetes-preserve-unknown-fields: true + type: array ownerReferences: description: OwnerReferences is the list of owner references. items: diff --git a/config/crd/bases/operator.ibm.com_operandconfigs.yaml b/config/crd/bases/operator.ibm.com_operandconfigs.yaml index 25503e44..f17f5343 100644 --- a/config/crd/bases/operator.ibm.com_operandconfigs.yaml +++ b/config/crd/bases/operator.ibm.com_operandconfigs.yaml @@ -105,6 +105,106 @@ spec: namespace: description: Namespace is the namespace of the resource. type: string + optionalFields: + description: OptionalFields is the list of fields that + could be updated additionally. + items: + description: OptionalField defines the optional field + for the resource. + properties: + matchExpressions: + description: MatchExpressions is the match expression + of the field. + items: + description: MatchExpression defines the match + expression of the field. + properties: + key: + description: Key is the key of the field. + type: string + objectRef: + description: ObjectRef is the reference of + the object. + properties: + apiVersion: + description: APIVersion is the version + of the object. + type: string + kind: + description: Kind is the kind of the object. + type: string + name: + description: Name is the name of the object. + type: string + namespace: + description: Namespace is the namespace + of the object. + type: string + required: + - apiVersion + - kind + - name + type: object + operator: + description: Operator is the operator of the + field. + type: string + values: + description: Values is the values of the field. + items: + type: string + type: array + required: + - key + - operator + type: object + x-kubernetes-preserve-unknown-fields: true + type: array + operation: + description: Operation is the operation of the field. + type: string + path: + description: Path is the json path of the field. + type: string + valueFrom: + description: ValueFrom is the field value from the + object + properties: + objectRef: + description: ObjectRef is the reference of the + object. + properties: + apiVersion: + description: APIVersion is the version of + the object. + type: string + kind: + description: Kind is the kind of the object. + type: string + name: + description: Name is the name of the object. + type: string + namespace: + description: Namespace is the namespace + of the object. + type: string + required: + - apiVersion + - kind + - name + type: object + path: + description: Path is the json path of the field. + type: string + required: + - path + type: object + required: + - operation + - path + type: object + x-kubernetes-preserve-unknown-fields: true + type: array ownerReferences: description: OwnerReferences is the list of owner references. items: diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 080d4471..a3017386 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -4,6 +4,12 @@ kind: ClusterRole metadata: name: operand-deployment-lifecycle-manager rules: +- apiGroups: + - apiextensions.k8s.io + resources: + - customresourcedefinitions + verbs: + - get - apiGroups: - operator.ibm.com resources: diff --git a/controllers/operandrequest/operandrequest_controller.go b/controllers/operandrequest/operandrequest_controller.go index e7429d38..458fd504 100644 --- a/controllers/operandrequest/operandrequest_controller.go +++ b/controllers/operandrequest/operandrequest_controller.go @@ -63,6 +63,7 @@ type clusterObjects struct { //+kubebuilder:rbac:groups=operator.ibm.com,resources=certmanagers;auditloggings,verbs=get;delete //+kubebuilder:rbac:groups=operators.coreos.com,resources=catalogsources,verbs=get +//+kubebuilder:rbac:groups=apiextensions.k8s.io,resources=customresourcedefinitions,verbs=get //+kubebuilder:rbac:groups=*,namespace="placeholder",resources=*,verbs=create;delete;get;list;patch;update;watch //+kubebuilder:rbac:groups=operator.ibm.com,namespace="placeholder",resources=operandrequests;operandrequests/status;operandrequests/finalizers,verbs=get;list;watch;create;update;patch;delete diff --git a/controllers/operandrequest/reconcile_operand.go b/controllers/operandrequest/reconcile_operand.go index 04964fbc..b70dc387 100644 --- a/controllers/operandrequest/reconcile_operand.go +++ b/controllers/operandrequest/reconcile_operand.go @@ -565,14 +565,14 @@ func (r *Reconciler) reconcileK8sResource(ctx context.Context, res operatorv1alp if err != nil && !apierrors.IsNotFound(err) { return errors.Wrapf(err, "failed to get k8s resource %s/%s", k8sResNs, res.Name) } else if apierrors.IsNotFound(err) { - if err := r.createK8sResource(ctx, k8sRes, res.Data, res.Labels, res.Annotations, &res.OwnerReferences); err != nil { + if err := r.createK8sResource(ctx, k8sRes, res.Data, res.Labels, res.Annotations, &res.OwnerReferences, &res.OptionalFields); err != nil { return err } } else { if res.Force { // Update k8s resource klog.V(3).Info("Found existing k8s resource: " + res.Name) - if err := r.updateK8sResource(ctx, k8sRes, res.Data, res.Labels, res.Annotations, &res.OwnerReferences); err != nil { + if err := r.updateK8sResource(ctx, k8sRes, res.Data, res.Labels, res.Annotations, &res.OwnerReferences, &res.OptionalFields); err != nil { return err } } else { @@ -999,7 +999,7 @@ func (r *Reconciler) checkCustomResource(ctx context.Context, requestInstance *o return nil } -func (r *Reconciler) createK8sResource(ctx context.Context, k8sResTemplate unstructured.Unstructured, k8sResConfig *runtime.RawExtension, newLabels, newAnnotations map[string]string, ownerReferences *[]operatorv1alpha1.OwnerReference) error { +func (r *Reconciler) createK8sResource(ctx context.Context, k8sResTemplate unstructured.Unstructured, k8sResConfig *runtime.RawExtension, newLabels, newAnnotations map[string]string, ownerReferences *[]operatorv1alpha1.OwnerReference, optionalFields *[]operatorv1alpha1.OptionalField) error { kind := k8sResTemplate.GetKind() name := k8sResTemplate.GetName() namespace := k8sResTemplate.GetNamespace() @@ -1040,6 +1040,9 @@ func (r *Reconciler) createK8sResource(ctx context.Context, k8sResTemplate unstr } } + if err := r.ExecuteOptionalFields(ctx, &k8sResTemplate, optionalFields); err != nil { + return errors.Wrap(err, "failed to execute optional fields") + } r.EnsureLabel(k8sResTemplate, map[string]string{constant.OpreqLabel: "true"}) r.EnsureLabel(k8sResTemplate, newLabels) r.EnsureAnnotation(k8sResTemplate, newAnnotations) @@ -1058,21 +1061,21 @@ func (r *Reconciler) createK8sResource(ctx context.Context, k8sResTemplate unstr return nil } -func (r *Reconciler) updateK8sResource(ctx context.Context, existingK8sRes unstructured.Unstructured, k8sResConfig *runtime.RawExtension, newLabels, newAnnotations map[string]string, ownerReferences *[]operatorv1alpha1.OwnerReference) error { +func (r *Reconciler) updateK8sResource(ctx context.Context, existingK8sRes unstructured.Unstructured, k8sResConfig *runtime.RawExtension, newLabels, newAnnotations map[string]string, ownerReferences *[]operatorv1alpha1.OwnerReference, optionalFields *[]operatorv1alpha1.OptionalField) error { kind := existingK8sRes.GetKind() apiversion := existingK8sRes.GetAPIVersion() name := existingK8sRes.GetName() namespace := existingK8sRes.GetNamespace() if kind == "Job" { - if err := r.updateK8sJob(ctx, existingK8sRes, k8sResConfig, newLabels, newAnnotations, ownerReferences); err != nil { + if err := r.updateK8sJob(ctx, existingK8sRes, k8sResConfig, newLabels, newAnnotations, ownerReferences, optionalFields); err != nil { return errors.Wrap(err, "failed to update Job") } return nil } if kind == "Route" { - if err := r.updateK8sRoute(ctx, existingK8sRes, k8sResConfig, newLabels, newAnnotations, ownerReferences); err != nil { + if err := r.updateK8sRoute(ctx, existingK8sRes, k8sResConfig, newLabels, newAnnotations, ownerReferences, optionalFields); err != nil { return errors.Wrap(err, "failed to update Route") } @@ -1150,6 +1153,9 @@ func (r *Reconciler) updateK8sResource(ctx context.Context, existingK8sRes unstr existingRes.Object = updatedExistingRes } + if err := r.ExecuteOptionalFields(ctx, &existingRes, optionalFields); err != nil { + return false, errors.Wrap(err, "failed to execute optional fields") + } r.EnsureAnnotation(existingRes, newAnnotations) r.EnsureLabel(existingRes, newLabels) if err := r.setOwnerReferences(ctx, &existingRes, ownerReferences); err != nil { @@ -1197,7 +1203,7 @@ func (r *Reconciler) updateK8sResource(ctx context.Context, existingK8sRes unstr return nil } -func (r *Reconciler) updateK8sJob(ctx context.Context, existingK8sRes unstructured.Unstructured, k8sResConfig *runtime.RawExtension, newLabels, newAnnotations map[string]string, ownerReferences *[]operatorv1alpha1.OwnerReference) error { +func (r *Reconciler) updateK8sJob(ctx context.Context, existingK8sRes unstructured.Unstructured, k8sResConfig *runtime.RawExtension, newLabels, newAnnotations map[string]string, ownerReferences *[]operatorv1alpha1.OwnerReference, optionalFields *[]operatorv1alpha1.OptionalField) error { kind := existingK8sRes.GetKind() apiversion := existingK8sRes.GetAPIVersion() @@ -1250,7 +1256,7 @@ func (r *Reconciler) updateK8sJob(ctx context.Context, existingK8sRes unstructur if err := r.deleteK8sResource(ctx, existingRes, namespace); err != nil { return errors.Wrap(err, "failed to update k8s resource") } - if err := r.createK8sResource(ctx, templatek8sRes, k8sResConfig, newLabels, newAnnotations, ownerReferences); err != nil { + if err := r.createK8sResource(ctx, templatek8sRes, k8sResConfig, newLabels, newAnnotations, ownerReferences, optionalFields); err != nil { return errors.Wrap(err, "failed to update k8s resource") } } @@ -1258,7 +1264,7 @@ func (r *Reconciler) updateK8sJob(ctx context.Context, existingK8sRes unstructur } // update route resource -func (r *Reconciler) updateK8sRoute(ctx context.Context, existingK8sRes unstructured.Unstructured, k8sResConfig *runtime.RawExtension, newLabels, newAnnotations map[string]string, ownerReferences *[]operatorv1alpha1.OwnerReference) error { +func (r *Reconciler) updateK8sRoute(ctx context.Context, existingK8sRes unstructured.Unstructured, k8sResConfig *runtime.RawExtension, newLabels, newAnnotations map[string]string, ownerReferences *[]operatorv1alpha1.OwnerReference, optionalFields *[]operatorv1alpha1.OptionalField) error { kind := existingK8sRes.GetKind() apiversion := existingK8sRes.GetAPIVersion() name := existingK8sRes.GetName() @@ -1309,7 +1315,7 @@ func (r *Reconciler) updateK8sRoute(ctx context.Context, existingK8sRes unstruct if err := r.deleteK8sResource(ctx, existingRes, namespace); err != nil { return errors.Wrap(err, "failed to delete Route for recreation") } - if err := r.createK8sResource(ctx, templatek8sRes, k8sResConfig, newLabels, newAnnotations, ownerReferences); err != nil { + if err := r.createK8sResource(ctx, templatek8sRes, k8sResConfig, newLabels, newAnnotations, ownerReferences, optionalFields); err != nil { return errors.Wrap(err, "failed to update k8s resource") } } @@ -1511,6 +1517,84 @@ func (r *Reconciler) setOwnerReferences(ctx context.Context, controlledRes *unst return nil } +func (r *Reconciler) ExecuteOptionalFields(ctx context.Context, resTemplate *unstructured.Unstructured, optionalFields *[]operatorv1alpha1.OptionalField) error { + if optionalFields != nil { + for _, field := range *optionalFields { + // Find the path from resTemplate + if value, err := util.SanitizeObjectString(field.Path, resTemplate.Object); err != nil || value == "" { + klog.Warningf("Skipping execute optional field, not find the path %s in the object -- Kind: %s, NamespacedName: %s/%s: %v", field.Path, resTemplate.GetKind(), resTemplate.GetNamespace(), resTemplate.GetName(), err) + continue + } + // Find the match expressions + if field.MatchExpressions != nil { + if !r.findMatchExpressions(ctx, field.MatchExpressions) { + klog.Infof("Skip operation '%s' for optional fields: %v for %s %s/%s", field.Operation, field.Path, resTemplate.GetKind(), resTemplate.GetNamespace(), resTemplate.GetName()) + continue + } + } + // Do operation + switch field.Operation { + case operatorv1alpha1.OperationRemove: + util.RemoveObjectField(resTemplate.Object, field.Path) + // case "Add": # TODO + default: + klog.Warningf("Invalid operation '%s' in optional fields: %v", field.Operation, field) + } + } + } + return nil +} + +func (r *Reconciler) findMatchExpressions(ctx context.Context, matchExpressions []operatorv1alpha1.MatchExpression) bool { + isMatch := false + for _, matchExpression := range matchExpressions { + if matchExpression.Key == "" || matchExpression.Operator == "" || matchExpression.ObjectRef == nil { + klog.Warningf("Invalid matchExpression: %v", matchExpression) + continue + } + // find value from the object + objRef := &unstructured.Unstructured{} + objRef.SetAPIVersion(matchExpression.ObjectRef.APIVersion) + objRef.SetKind(matchExpression.ObjectRef.Kind) + objRef.SetName(matchExpression.ObjectRef.Name) + if err := r.Reader.Get(ctx, types.NamespacedName{ + Name: matchExpression.ObjectRef.Name, + Namespace: matchExpression.ObjectRef.Namespace, + }, objRef); err != nil { + klog.Warningf("Failed to get the object %v in match expressions: %v", matchExpression.ObjectRef, err) + continue + } + value, _ := util.SanitizeObjectString(matchExpression.Key, objRef.Object) + + switch matchExpression.Operator { + case operatorv1alpha1.OperatorIn: + if util.Contains(matchExpression.Values, value) { + return true + } + case operatorv1alpha1.OperatorNotIn: + if util.Contains(matchExpression.Values, value) { + return false + } else { + isMatch = true + } + case operatorv1alpha1.OperatorExists: + if value != "" { + return true + } + case operatorv1alpha1.OperatorDoesNotExist: + if value != "" { + return false + } else { + isMatch = true + } + default: + klog.Warningf("Invalid operator %s in match expressions: %v", matchExpression.Operator, matchExpression) + } + } + + return isMatch +} + func (r *Reconciler) ServiceStatusIsReady(ctx context.Context, requestInstance *operatorv1alpha1.OperandRequest) (bool, error) { requestedServicesSet := make(map[string]struct{}) for _, req := range requestInstance.Spec.Requests { diff --git a/controllers/operandrequest/reconcile_operand_test.go b/controllers/operandrequest/reconcile_operand_test.go index 69bf5ac6..a88af6a0 100644 --- a/controllers/operandrequest/reconcile_operand_test.go +++ b/controllers/operandrequest/reconcile_operand_test.go @@ -20,6 +20,7 @@ import ( "context" "testing" + "github.com/pkg/errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -116,3 +117,171 @@ func TestSetOwnerReferences(t *testing.T) { } assert.Equal(t, expectedOwnerReferences, controlledRes.GetOwnerReferences()) } +func TestFindMatchExpressions(t *testing.T) { + // Create a fake client + client := fake.NewClientBuilder().Build() + + // Create a mock reader + reader := &MockReader{} + + // Create a reconciler instance + r := &Reconciler{ + ODLMOperator: &deploy.ODLMOperator{ + Client: client, + Reader: reader, + }, + } + + // Define test cases + tests := []struct { + name string + matchExpressions []operatorv1alpha1.MatchExpression + expectedResult bool + mockGetError error + mockGetValue string + }{ + { + name: "Valid In operator", + matchExpressions: []operatorv1alpha1.MatchExpression{ + { + Key: ".spec.replicas", + Operator: operatorv1alpha1.OperatorIn, + Values: []string{"3", "4"}, + ObjectRef: &operatorv1alpha1.ObjectRef{ + APIVersion: "apps/v1", + Kind: "Deployment", + Name: "test-deployment", + Namespace: "test-namespace", + }, + }, + }, + expectedResult: true, + mockGetValue: "3", + }, + { + name: "Valid NotIn operator", + matchExpressions: []operatorv1alpha1.MatchExpression{ + { + Key: ".spec.replicas", + Operator: operatorv1alpha1.OperatorNotIn, + Values: []string{"4", "5"}, + ObjectRef: &operatorv1alpha1.ObjectRef{ + APIVersion: "apps/v1", + Kind: "Deployment", + Name: "test-deployment", + Namespace: "test-namespace", + }, + }, + }, + expectedResult: true, + mockGetValue: "3", + }, + { + name: "Valid Exists operator", + matchExpressions: []operatorv1alpha1.MatchExpression{ + { + Key: ".spec.replicas", + Operator: operatorv1alpha1.OperatorExists, + ObjectRef: &operatorv1alpha1.ObjectRef{ + APIVersion: "apps/v1", + Kind: "Deployment", + Name: "test-deployment", + Namespace: "test-namespace", + }, + }, + }, + expectedResult: true, + mockGetValue: "1", + }, + { + name: "Valid DoesNotExist operator", + matchExpressions: []operatorv1alpha1.MatchExpression{ + { + Key: ".spec.replicas", + Operator: operatorv1alpha1.OperatorDoesNotExist, + ObjectRef: &operatorv1alpha1.ObjectRef{ + APIVersion: "apps/v1", + Kind: "Deployment", + Name: "test-deployment", + Namespace: "test-namespace", + }, + }, + }, + expectedResult: false, + mockGetValue: "1", + }, + { + name: "Valid DoesNotExist operator with non-exist parh .spec.replica", + matchExpressions: []operatorv1alpha1.MatchExpression{ + { + Key: ".spec.replica", + Operator: operatorv1alpha1.OperatorDoesNotExist, + ObjectRef: &operatorv1alpha1.ObjectRef{ + APIVersion: "apps/v1", + Kind: "Deployment", + Name: "test-deployment", + Namespace: "test-namespace", + }, + }, + }, + expectedResult: true, + mockGetValue: "", + }, + { + name: "Invalid match expression", + matchExpressions: []operatorv1alpha1.MatchExpression{ + { + Key: "", + Operator: "In", + Values: []string{"3"}, + ObjectRef: &operatorv1alpha1.ObjectRef{ + APIVersion: "apps/v1", + Kind: "Deployment", + Name: "test-deployment", + Namespace: "test-namespace", + }, + }, + }, + expectedResult: false, + }, + { + name: "Failed to get object with path .spec.replica", + matchExpressions: []operatorv1alpha1.MatchExpression{ + { + Key: ".spec.replica", + Operator: operatorv1alpha1.OperatorIn, + Values: []string{"3"}, + ObjectRef: &operatorv1alpha1.ObjectRef{ + APIVersion: "apps/v1", + Kind: "Deployment", + Name: "test-deployment", + Namespace: "test-namespace", + }, + }, + }, + expectedResult: false, + mockGetError: errors.New("failed to get object"), + mockGetValue: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Mock the Get method of the reader + reader.On("Get", mock.Anything, types.NamespacedName{Name: "test-deployment", Namespace: "test-namespace"}, mock.AnythingOfType("*unstructured.Unstructured")).Return(tt.mockGetError).Run(func(args mock.Arguments) { + if tt.mockGetError == nil { + obj := args.Get(2).(*unstructured.Unstructured) + obj.Object["spec"] = map[string]interface{}{ + "replicas": tt.mockGetValue, + } + } + }) + + // Call the findMatchExpressions function + result := r.findMatchExpressions(context.Background(), tt.matchExpressions) + + // Assert the result + assert.Equal(t, tt.expectedResult, result) + }) + } +} diff --git a/controllers/util/util.go b/controllers/util/util.go index eaa06478..4545016e 100644 --- a/controllers/util/util.go +++ b/controllers/util/util.go @@ -436,3 +436,92 @@ func FindMinSemverFromAnnotations(annotations map[string]string, curChannel stri semverlList, semVerChannelMappings := FindSemverFromAnnotations(annotations) return FindMinSemver(curChannel, semverlList, semVerChannelMappings) } + +// RemoveObjectField removes the field from the object according to the jsonPath +// jsonPath is a string that represents the path to the field in the object, always starts with "." +func RemoveObjectField(obj interface{}, jsonPath string) { + // Remove the first dot in the beginning of the jsonPath + jsonPath = strings.TrimPrefix(jsonPath, ".") + fields := strings.Split(jsonPath, ".") + + // Check if the object is a map + if objMap, ok := obj.(map[string]interface{}); ok { + removeField(objMap, fields) + } +} + +// removeField removes the field from the object according to the jsonPath +func removeField(obj map[string]interface{}, fields []string) { + // Check if fields is in the format of one item in the list. For example "container[0]" + if strings.Contains(fields[0], "[") { + // Get the field name and the index + field := strings.Split(fields[0], "[")[0] + index, _ := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(fields[0], field+"["), "]")) + // Check if the field is a list + if _, ok := obj[field].([]interface{}); !ok { + return + } + // Check if the index is out of range + if index >= len(obj[field].([]interface{})) { + return + } + // Remove the value from the list + removeField(obj[field].([]interface{})[index].(map[string]interface{}), fields[1:]) + return + } + // Check if the field is the last field in the list + if len(fields) == 1 { + delete(obj, fields[0]) + return + } + // Check if the field is a map + if _, ok := obj[fields[0]].(map[string]interface{}); !ok { + return + } + // Remove the value from the map + removeField(obj[fields[0]].(map[string]interface{}), fields[1:]) +} + +// AddObjectField adds the field to the object according to the jsonPath +// jsonPath is a string that represents the path to the field in the object, always starts with "." +func AddObjectField(obj interface{}, jsonPath string, value interface{}) { + // Remove the first dot in the beginning of the jsonPath if it exists + jsonPath = strings.TrimPrefix(jsonPath, ".") + fields := strings.Split(jsonPath, ".") + + // Check if the object is a map + if objMap, ok := obj.(map[string]interface{}); ok { + addField(objMap, fields, value) + } +} + +func addField(obj map[string]interface{}, fields []string, value interface{}) { + // Check if fields is in the format of one item in the list. For example "container[0]" + if strings.Contains(fields[0], "[") { + // Get the field name and the index + field := strings.Split(fields[0], "[")[0] + index, _ := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(fields[0], field+"["), "]")) + // Check if the field is a list + if _, ok := obj[field].([]interface{}); !ok { + obj[field] = make([]interface{}, 0) + } + // Check if the index is out of range + if index >= len(obj[field].([]interface{})) { + obj[field] = append(obj[field].([]interface{}), make(map[string]interface{})) + } + // Add the value to the list + addField(obj[field].([]interface{})[index].(map[string]interface{}), fields[1:], value) + return + } + // Check if the field is the last field in the list + if len(fields) == 1 { + obj[fields[0]] = value + return + } + // Check if the field is a map + if _, ok := obj[fields[0]].(map[string]interface{}); !ok { + obj[fields[0]] = make(map[string]interface{}) + } + // Add the value to the map + addField(obj[fields[0]].(map[string]interface{}), fields[1:], value) +} diff --git a/controllers/util/util_test.go b/controllers/util/util_test.go index 311df979..9f70e6c6 100644 --- a/controllers/util/util_test.go +++ b/controllers/util/util_test.go @@ -21,6 +21,7 @@ import ( . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ) var _ = Describe("Get environmental variables", func() { @@ -258,3 +259,250 @@ var _ = Describe("FindMaxSemver", func() { Expect(FindMaxSemver(curChannel, semverList, semVerChannelMappings)).Should(Equal(expected)) }) }) + +var _ = Describe("RemoveObjectField", func() { + It("Should remove the specified field from the object", func() { + obj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "test", + "namespace": "default", + }, + "spec": map[string]interface{}{ + "replicas": 3, + }, + }, + } + + RemoveObjectField(obj.Object, ".metadata.namespace") + + expected := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "test", + }, + "spec": map[string]interface{}{ + "replicas": 3, + }, + }, + } + + Expect(obj).Should(Equal(expected)) + }) + + It("Should remove nested fields from the object", func() { + obj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "test", + "namespace": "default", + "labels": map[string]interface{}{ + "app": "myapp", + }, + }, + "spec": map[string]interface{}{ + "replicas": 3, + }, + }, + } + + RemoveObjectField(obj.Object, ".metadata.labels.app") + + expected := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "test", + "namespace": "default", + "labels": map[string]interface{}{}, + }, + "spec": map[string]interface{}{ + "replicas": 3, + }, + }, + } + + Expect(obj).Should(Equal(expected)) + }) + + It("Should do nothing if the field does not exist", func() { + obj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "test", + "namespace": "default", + }, + "spec": map[string]interface{}{ + "replicas": 3, + }, + }, + } + + RemoveObjectField(obj.Object, ".metadata.labels.app") + + expected := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "test", + "namespace": "default", + }, + "spec": map[string]interface{}{ + "replicas": 3, + }, + }, + } + + Expect(obj).Should(Equal(expected)) + }) + + It("Should remove field and all its nested fields from the object", func() { + obj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "test", + "namespace": "default", + "labels": map[string]interface{}{ + "app": "myapp", + }, + }, + "spec": map[string]interface{}{ + "replicas": 3, + }, + }, + } + + RemoveObjectField(obj.Object, ".metadata") + + expected := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "replicas": 3, + }, + }, + } + + Expect(obj).Should(Equal(expected)) + }) +}) + +var _ = Describe("AddObjectField", func() { + It("Should add a new field to the object", func() { + obj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "test", + }, + "spec": map[string]interface{}{ + "replicas": 3, + }, + }, + } + + AddObjectField(obj.Object, ".metadata.namespace", "default") + + expected := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "test", + "namespace": "default", + }, + "spec": map[string]interface{}{ + "replicas": 3, + }, + }, + } + + Expect(obj).Should(Equal(expected)) + }) + + It("Should add a nested field to the object", func() { + obj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "test", + }, + "spec": map[string]interface{}{ + "replicas": 3, + }, + }, + } + + AddObjectField(obj.Object, ".metadata.labels.app", "myapp") + + expected := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "test", + "labels": map[string]interface{}{ + "app": "myapp", + }, + }, + "spec": map[string]interface{}{ + "replicas": 3, + }, + }, + } + + Expect(obj).Should(Equal(expected)) + }) + + It("Should overwrite an existing field in the object", func() { + obj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "test", + }, + "spec": map[string]interface{}{ + "replicas": 3, + }, + }, + } + + AddObjectField(obj.Object, ".spec.replicas", 5) + + expected := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "test", + }, + "spec": map[string]interface{}{ + "replicas": 5, + }, + }, + } + + Expect(obj).Should(Equal(expected)) + }) + + It("Should create nested maps if they do not exist", func() { + obj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "test", + }, + }, + } + + AddObjectField(obj.Object, ".spec.template.spec.containers[0].name", "nginx") + + expected := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "test", + }, + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "spec": map[string]interface{}{ + "containers": []interface{}{ + map[string]interface{}{ + "name": "nginx", + }, + }, + }, + }, + }, + }, + } + + Expect(obj).Should(Equal(expected)) + }) +})