Repository: flant/shell-operator
Pull request #239 (2020-12-25 11:03:49)

Object manipulation framework
<!--
Thank you for sending a pull request! Here some tips for contributors:

1. Fill the description template below.
2. Include appropriate tests (if necessary). Make sure that all CI checks passed.
3. If the Pull Request is a work in progress, make use of GitHub's "Draft PR" feature and mark it as such.
-->

#### Overview

Provide a way to delegate Kubernetes object manipulation to the shell-operator. Will remove the need to use kubectl.

<!-- Describe your changes briefly here. -->

#### What this PR does / why we need it

<!--
- Please state in detail why we need this PR and what it solves.
- If your PR closes some of the existing issues, please add links to them here.
  Mentioned issues will be automatically closed.
  Usage: "Closes #<issue number>", or "Closes (paste link of issue)"
-->

#### Special notes for your reviewer

#### Does this PR introduce a user-facing change?

<!--
If no, just write "NONE" in the release-note block below.
If yes, a release note is required:
Enter your extended release note in the block below. If the PR requires additional action from users switching to the new release, include the string "action required".
-->

```release-note

```


Patch:
diff --git a/KUBERNETES.md b/KUBERNETES.md
new file mode 100644
index 0000000..a18e13b
--- /dev/null
+++ b/KUBERNETES.md
@@ -0,0 +1,186 @@
+# Modifying Kubernetes objects
+
+You can delegate Kubernetes object manipulation to the shell-operator.
+
+To do this, you have to write one or more JSON or YAML documents describing operation type and its parameters to a file.
+List of possible operations and corresponding JSON specifications can be found below.
+
+The path to the file is found in the `$KUBERNETES_PATCH_PATH` environment variable.
+
+## Operations
+
+### Create
+
+* `operation` — specifies an operation's type.
+    * `CreateOrUpdate` — accept a Kubernetes object.
+      It retrieves an object, and if it already exists, computes a JSON Merge Patch and applies it (will not update .status field).
+      If it does not exist, we create the object.
+    * `Create` — will fail if an object already exists
+    * `CreateIfNotExists` — create an object if such an object does not already
+      exist by namespace/name.
+* `object` — full object specification including "apiVersion", "kind" and all necessary metadata.
+
+#### Example
+
+```json
+{
+  "operation": "CreateOrUpdate",
+  "object": {
+    "apiVersion": "apps/v1",
+    "kind": "DaemonSet",
+    "metadata": {
+      "name": "flannel",
+      "namespace": "d8-flannel"
+    },
+    "spec": {
+      "selector": {
+        "matchLabels": {
+          "app": "flannel"
+        }
+      },
+      "template": {
+        "metadata": {
+          "labels": {
+            "app": "flannel",
+            "tier": "node"
+          }
+        },
+        "spec": {
+          "containers": [
+            {
+              "args": [
+                "--ip-masq",
+                "--kube-subnet-mgr"
+              ],
+              "image": "flannel:v0.11",
+              "name": "kube-flannel",
+              "securityContext": {
+                "privileged": true
+              }
+            }
+          ],
+          "hostNetwork": true,
+          "imagePullSecrets": [
+            {
+              "name": "registry"
+            }
+          ],
+          "terminationGracePeriodSeconds": 5
+        }
+      },
+      "updateStrategy": {
+        "type": "RollingUpdate"
+      }
+    }
+  }
+}
+```
+
+### Delete
+
+* `operation` — specifies an operation's type. Deletion types map directly to Kubernetes
+  DELETE's [`propagationPolicy`](https://kubernetes.io/docs/concepts/workloads/controllers/garbage-collection/).
+  * `Delete` — foreground deletion. Hook will block the queue until the referenced object and all its descendants are deleted.
+  * `DeleteInBackground` — will delete the referenced object immediately. All its descendants will be removed by Kubernetes'
+    garbage collector.
+  * `DeleteNonCascading` — will delete the referenced object immediately, and orphan all its descendants.
+* `apiVersion` — optional field that specifies object's apiVersion. If not present, we'll use preferred apiVersion
+  for the given kind.
+* `kind` — object's Kind.
+* `namespace` — object's namespace. If empty, implies operation on a cluster-level resource.
+* `name` — object's name.
+* `subresource` — a subresource name if subresource is to be transformed. For example, `status`.
+
+#### Example
+
+```json
+{
+  "operation": "Delete",
+  "kind": "Pod",
+  "namespace": "default",
+  "name": "nginx"
+}
+```
+
+### Patch
+
+Use `JQPatch` for almost everything. Consider using `MergePatch` or `JSONPatch` if you are attempting to modify 
+rapidly changing object, for example `status` field with many concurrent changes (and incrementing `resourceVersion`).
+
+Be careful, when updating a `.status` field. If a `/status` subresource is enabled on a resource,
+it'll ignore updates to the `.status` field if you haven't specified `subresource: status` in the operation spec.
+More info [here](https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#spec-and-status).
+
+#### JQPatch
+
+* `operation` — specifies an operation's type.
+* `apiVersion` — optional field that specifies object's apiVersion. If not present, we'll use preferred apiVersion
+  for the given kind.
+* `kind` — object's Kind.
+* `namespace` — object's Namespace. If empty, implies operation on a Cluster-level resource.
+* `name` — object's name.
+* `jqFilter` — describes transformations to perform on an object.
+* `subresource` — a subresource name if subresource is to be transformed. For example, `status`.
+##### Example
+
+```json
+{
+  "operation": "JQPatch",
+  "kind": "Deployment",
+  "namespace": "default",
+  "name": "nginx",
+  "jqFilter": ".spec.replicas = 1"
+}
+```
+
+#### MergePatch
+
+* `operation` — specifies an operation's type.
+* `apiVersion` — optional field that specifies object's apiVersion. If not present, we'll use preferred apiVersion
+  for the given kind.
+* `kind` — object's Kind.
+* `namespace` — object's Namespace. If empty, implies operation on a Cluster-level resource.
+* `name` — object's name.
+* `mergePatch` — describes transformations to perform on an object.
+* `subresource` — e.g., `status`.
+
+##### Example
+
+```json
+{
+  "operation": "MergePatch",
+  "kind": "Deployment",
+  "namespace": "default",
+  "name": "nginx",
+  "mergePatch": {
+    "spec": {
+      "replicas": 1
+    }
+  }
+}
+```
+
+#### JSONPatch
+
+* `operation` — specifies an operation's type.
+* `apiVersion` — optional field that specifies object's apiVersion. If not present, we'll use preferred apiVersion
+  for the given kind.
+* `kind` — object's Kind.
+* `namespace` — object's Namespace. If empty, implies operation on a Cluster-level resource.
+* `name` — object's name.
+* `jsonPatch` — describes transformations to perform on an object.
+* `subresource` — a subresource name if subresource is to be transformed. For example, `status`.
+
+##### Example
+
+```json
+{
+  "operation": "JSONPatch",
+  "kind": "Deployment",
+  "namespace": "default",
+  "name": "nginx",
+  "jsonPatch": [
+    {"op": "replace", "path":  "/spec/replicas", "value":  1}
+  ]
+}
+```
\ No newline at end of file
diff --git a/go.mod b/go.mod
index cfda29f..a74ce03 100644
--- a/go.mod
+++ b/go.mod
@@ -21,6 +21,7 @@ require (
 	gopkg.in/alecthomas/kingpin.v2 v2.2.6
 	gopkg.in/robfig/cron.v2 v2.0.0-20150107220207-be2e0b0deed5
 	gopkg.in/satori/go.uuid.v1 v1.2.0
+	gopkg.in/yaml.v3 v3.0.0-20191120175047-4206685974f2
 	k8s.io/api v0.17.0
 	k8s.io/apiextensions-apiserver v0.17.0
 	k8s.io/apimachinery v0.17.0
diff --git a/pkg/hook/binding_context/binding_context.go b/pkg/hook/binding_context/binding_context.go
index 0f3ac18..05fa173 100644
--- a/pkg/hook/binding_context/binding_context.go
+++ b/pkg/hook/binding_context/binding_context.go
@@ -1,4 +1,4 @@
-package hook
+package binding_context
 
 import (
 	"encoding/json"
diff --git a/pkg/hook/hook.go b/pkg/hook/hook.go
index 3ce1c7a..08abe82 100644
--- a/pkg/hook/hook.go
+++ b/pkg/hook/hook.go
@@ -30,10 +30,11 @@ type CommonHook interface {
 }
 
 type HookResult struct {
-	Usage              *executor.CmdUsage
-	Metrics            []operation.MetricOperation
-	ConversionResponse *conversion.Response
-	ValidatingResponse *ValidatingResponse
+	Usage                *executor.CmdUsage
+	Metrics              []operation.MetricOperation
+	ConversionResponse   *conversion.Response
+	ValidatingResponse   *ValidatingResponse
+	KubernetesPatchBytes []byte
 }
 
 type Hook struct {
@@ -108,12 +109,18 @@ func (h *Hook) Run(bindingType BindingType, context []BindingContext, logLabels
 		return nil, err
 	}
 
+	kubernetesPatchPath, err := h.prepareObjectPatchFile()
+	if err != nil {
+		return nil, err
+	}
+
 	// remove tmp file on hook exit
 	defer func() {
 		if app.DebugKeepTmpFiles != "yes" {
 			os.Remove(contextPath)
 			os.Remove(metricsPath)
 			os.Remove(validatingPath)
+			os.Remove(kubernetesPatchPath)
 		}
 	}()
 
@@ -124,6 +131,7 @@ func (h *Hook) Run(bindingType BindingType, context []BindingContext, logLabels
 		envs = append(envs, fmt.Sprintf("METRICS_PATH=%s", metricsPath))
 		envs = append(envs, fmt.Sprintf("CONVERSION_RESPONSE_PATH=%s", conversionPath))
 		envs = append(envs, fmt.Sprintf("VALIDATING_RESPONSE_PATH=%s", validatingPath))
+		envs = append(envs, fmt.Sprintf("KUBERNETES_PATCH_PATH=%s", kubernetesPatchPath))
 	}
 
 	hookCmd := executor.MakeCommand(path.Dir(h.Path), h.Path, []string{}, envs)
@@ -150,6 +158,11 @@ func (h *Hook) Run(bindingType BindingType, context []BindingContext, logLabels
 		return result, fmt.Errorf("got bad conversion response: %s", err)
 	}
 
+	result.KubernetesPatchBytes, err = ioutil.ReadFile(kubernetesPatchPath)
+	if err != nil {
+		return result, fmt.Errorf("can't read object patch file: %s", err)
+	}
+
 	return result, nil
 }
 
@@ -283,3 +296,14 @@ func CreateRateLimiter(cfg *config.HookConfig) *rate.Limiter {
 	}
 	return rate.NewLimiter(limit, burst)
 }
+
+func (h *Hook) prepareObjectPatchFile() (string, error) {
+	objectPatchPath := filepath.Join(h.TmpDir, fmt.Sprintf("%s-object-patch-%s", h.SafeName(), uuid.NewV4().String()))
+
+	err := ioutil.WriteFile(objectPatchPath, []byte{}, 0644)
+	if err != nil {
+		return "", err
+	}
+
+	return objectPatchPath, nil
+}
diff --git a/pkg/kube/object_patch/patch.go b/pkg/kube/object_patch/patch.go
new file mode 100644
index 0000000..20e17c2
--- /dev/null
+++ b/pkg/kube/object_patch/patch.go
@@ -0,0 +1,355 @@
+package object_patch
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"io"
+	"time"
+
+	"github.com/hashicorp/go-multierror"
+	"gopkg.in/yaml.v3"
+	"k8s.io/apimachinery/pkg/api/equality"
+	"k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/apimachinery/pkg/util/wait"
+	"k8s.io/client-go/util/retry"
+
+	"github.com/flant/shell-operator/pkg/app"
+	"github.com/flant/shell-operator/pkg/jq"
+	"github.com/flant/shell-operator/pkg/kube"
+)
+
+type ObjectPatcher struct {
+	kubeClient kube.KubernetesClient
+}
+
+func NewObjectPatcher(kubeClient kube.KubernetesClient) *ObjectPatcher {
+	return &ObjectPatcher{kubeClient: kubeClient}
+}
+
+func ParseSpecs(specBytes []byte) ([]OperationSpec, error) {
+	specs, err := unmarshalFromJSONOrYAML(specBytes)
+	if err != nil {
+		return nil, err
+	}
+
+	var validationErrors = &multierror.Error{}
+	for _, spec := range specs {
+		err = ValidateOperationSpec(spec, GetSchema("v0"), "")
+		if err != nil {
+			validationErrors = multierror.Append(validationErrors, err)
+		}
+	}
+
+	return specs, validationErrors.ErrorOrNil()
+}
+
+func (o *ObjectPatcher) GenerateFromJSONAndExecuteOperations(specs []OperationSpec) error {
+	var applyErrors = &multierror.Error{}
+	for _, spec := range specs {
+		var operationError error
+
+		switch spec.Operation {
+		case Create:
+			operationError = o.CreateObject(&unstructured.Unstructured{Object: spec.Object}, spec.Subresource)
+		case CreateOrUpdate:
+			operationError = o.CreateOrUpdateObject(&unstructured.Unstructured{Object: spec.Object}, spec.Subresource)
+		case Delete:
+			operationError = o.DeleteObject(spec.ApiVersion, spec.Kind, spec.Namespace, spec.Name, spec.Subresource)
+		case DeleteInBackground:
+			operationError = o.DeleteObjectInBackground(spec.ApiVersion, spec.Kind, spec.Namespace, spec.Name, spec.Subresource)
+		case DeleteNonCascading:
+			operationError = o.DeleteObjectNonCascading(spec.ApiVersion, spec.Kind, spec.Namespace, spec.Name, spec.Subresource)
+		case JQPatch:
+			operationError = o.JQPatchObject(spec.JQFilter, spec.ApiVersion, spec.Kind, spec.Namespace, spec.Name, spec.Subresource)
+		case MergePatch:
+			jsonMergePatch, err := json.Marshal(spec.MergePatch)
+			if err != nil {
+				applyErrors = multierror.Append(applyErrors, err)
+				continue
+			}
+
+			operationError = o.MergePatchObject(jsonMergePatch, spec.ApiVersion, spec.Kind, spec.Namespace, spec.Name, spec.Subresource)
+		case JSONPatch:
+			jsonJsonPatch, err := json.Marshal(spec.JSONPatch)
+			if err != nil {
+				applyErrors = multierror.Append(applyErrors, err)
+				continue
+			}
+
+			operationError = o.JSONPatchObject(jsonJsonPatch, spec.ApiVersion, spec.Kind, spec.Namespace, spec.Name, spec.Subresource)
+		}
+
+		if operationError != nil {
+			applyErrors = multierror.Append(applyErrors, operationError)
+		}
+	}
+
+	return applyErrors.ErrorOrNil()
+}
+
+func unmarshalFromJSONOrYAML(specs []byte) ([]OperationSpec, error) {
+	fromJsonSpecs, err := unmarshalFromJson(specs)
+	if err != nil {
+		return unmarshalFromYaml(specs)
+	}
+
+	return fromJsonSpecs, nil
+}
+
+func unmarshalFromJson(jsonSpecs []byte) ([]OperationSpec, error) {
+	var specSlice []OperationSpec
+
+	dec := json.NewDecoder(bytes.NewReader(jsonSpecs))
+	for {
+		var doc OperationSpec
+		err := dec.Decode(&doc)
+		if err == io.EOF {
+			break
+		}
+		if err != nil {
+			return nil, err
+		}
+
+		specSlice = append(specSlice, doc)
+	}
+
+	return specSlice, nil
+}
+
+func unmarshalFromYaml(yamlSpecs []byte) ([]OperationSpec, error) {
+	var specSlice []OperationSpec
+
+	dec := yaml.NewDecoder(bytes.NewReader(yamlSpecs))
+	for {
+		var doc OperationSpec
+		err := dec.Decode(&doc)
+		if err == io.EOF {
+			break
+		}
+		if err != nil {
+			return nil, err
+		}
+
+		specSlice = append(specSlice, doc)
+	}
+
+	return specSlice, nil
+}
+
+func (o *ObjectPatcher) CreateObject(object *unstructured.Unstructured, subresource string) error {
+	if object == nil {
+		return fmt.Errorf("cannot create empty object")
+	}
+
+	apiVersion := object.GetAPIVersion()
+	kind := object.GetKind()
+
+	gvk, err := o.kubeClient.GroupVersionResource(apiVersion, kind)
+	if err != nil {
+		return err
+	}
+
+	_, err = o.kubeClient.Dynamic().Resource(gvk).Namespace(object.GetNamespace()).Create(object, metav1.CreateOptions{}, generateSubresources(subresource)...)
+
+	return err
+}
+
+func (o *ObjectPatcher) CreateOrUpdateObject(object *unstructured.Unstructured, subresource string) error {
+	if object == nil {
+		return fmt.Errorf("cannot create empty object")
+	}
+
+	apiVersion := object.GetAPIVersion()
+	kind := object.GetKind()
+
+	gvk, err := o.kubeClient.GroupVersionResource(apiVersion, kind)
+	if err != nil {
+		return err
+	}
+
+	_, err = o.kubeClient.Dynamic().Resource(gvk).Namespace(object.GetNamespace()).Create(object, metav1.CreateOptions{}, generateSubresources(subresource)...)
+	if errors.IsAlreadyExists(err) {
+		err = retry.RetryOnConflict(retry.DefaultBackoff, func() error {
+			existingObj, err := o.kubeClient.Dynamic().Resource(gvk).Namespace(object.GetNamespace()).Get(object.GetName(), metav1.GetOptions{}, generateSubresources(subresource)...)
+			if err != nil {
+				return err
+			}
+
+			objCopy := object.DeepCopy()
+			objCopy.SetResourceVersion(existingObj.GetResourceVersion())
+			_, err = o.kubeClient.Dynamic().Resource(gvk).Namespace(objCopy.GetNamespace()).Update(objCopy, metav1.UpdateOptions{}, generateSubresources(subresource)...)
+			return err
+		})
+	}
+
+	return err
+}
+
+func (o *ObjectPatcher) FilterObject(filterFunc func(*unstructured.Unstructured) (*unstructured.Unstructured, error),
+	apiVersion, kind, namespace, name, subresource string) error {
+
+	if filterFunc == nil {
+		return fmt.Errorf("FilterFunc is nil")
+	}
+
+	gvk, err := o.kubeClient.GroupVersionResource(apiVersion, kind)
+	if err != nil {
+		return err
+	}
+
+	err = retry.RetryOnConflict(retry.DefaultBackoff, func() error {
+		obj, err := o.kubeClient.Dynamic().Resource(gvk).Namespace(namespace).Get(name, metav1.GetOptions{})
+		if err != nil {
+			return err
+		}
+
+		filteredObj, err := filterFunc(obj)
+		if err != nil {
+			return err
+		}
+
+		if equality.Semantic.DeepEqual(obj, filteredObj) {
+			return nil
+		}
+
+		var filteredObjBuf bytes.Buffer
+		err = unstructured.UnstructuredJSONScheme.Encode(filteredObj, &filteredObjBuf)
+		if err != nil {
+			return err
+		}
+
+		_, err = o.kubeClient.Dynamic().Resource(gvk).Namespace(namespace).Update(filteredObj, metav1.UpdateOptions{}, generateSubresources(subresource)...)
+		if err != nil {
+			return err
+		}
+
+		return nil
+	})
+
+	return err
+}
+
+func (o *ObjectPatcher) JQPatchObject(jqPatch, apiVersion, kind, namespace, name, subresource string) error {
+	gvk, err := o.kubeClient.GroupVersionResource(apiVersion, kind)
+	if err != nil {
+		return err
+	}
+
+	err = retry.RetryOnConflict(retry.DefaultBackoff, func() error {
+		obj, err := o.kubeClient.Dynamic().Resource(gvk).Namespace(namespace).Get(name, metav1.GetOptions{})
+		if err != nil {
+			return err
+		}
+
+		patchedObj, err := applyJQPatch(jqPatch, obj)
+		if err != nil {
+			return err
+		}
+
+		_, err = o.kubeClient.Dynamic().Resource(gvk).Namespace(namespace).Update(patchedObj, metav1.UpdateOptions{}, generateSubresources(subresource)...)
+		if err != nil {
+			return err
+		}
+
+		return nil
+	})
+
+	return err
+}
+
+func (o *ObjectPatcher) MergePatchObject(mergePatch []byte, apiVersion, kind, namespace, name, subresource string) error {
+	gvk, err := o.kubeClient.GroupVersionResource(apiVersion, kind)
+	if err != nil {
+		return err
+	}
+
+	_, err = o.kubeClient.Dynamic().Resource(gvk).Namespace(namespace).Patch(name, types.MergePatchType, mergePatch, metav1.PatchOptions{}, generateSubresources(subresource)...)
+
+	return err
+}
+
+func (o *ObjectPatcher) JSONPatchObject(jsonPatch []byte, apiVersion, kind, namespace, name, subresource string) error {
+	gvk, err := o.kubeClient.GroupVersionResource(apiVersion, kind)
+	if err != nil {
+		return err
+	}
+
+	_, err = o.kubeClient.Dynamic().Resource(gvk).Namespace(namespace).Patch(name, types.JSONPatchType, jsonPatch, metav1.PatchOptions{}, generateSubresources(subresource)...)
+
+	return err
+}
+
+func (o *ObjectPatcher) DeleteObject(apiVersion, kind, namespace, name, subresource string) error {
+	return o.deleteObjectInternal(apiVersion, kind, namespace, name, subresource, metav1.DeletePropagationForeground)
+}
+
+func (o *ObjectPatcher) DeleteObjectInBackground(apiVersion, kind, namespace, name, subresource string) error {
+	return o.deleteObjectInternal(apiVersion, kind, namespace, name, subresource, metav1.DeletePropagationBackground)
+}
+
+func (o *ObjectPatcher) DeleteObjectNonCascading(apiVersion, kind, namespace, name, subresource string) error {
+	return o.deleteObjectInternal(apiVersion, kind, namespace, name, subresource, metav1.DeletePropagationOrphan)
+}
+
+func (o *ObjectPatcher) deleteObjectInternal(apiVersion, kind, namespace, name, subresource string, propagation metav1.DeletionPropagation) error {
+	gvk, err := o.kubeClient.GroupVersionResource(apiVersion, kind)
+	if err != nil {
+		return err
+	}
+
+	err = o.kubeClient.Dynamic().Resource(gvk).Namespace(namespace).Delete(name, &metav1.DeleteOptions{PropagationPolicy: &propagation}, subresource)
+	if errors.IsNotFound(err) {
+		return nil
+	}
+	if err != nil {
+		return err
+	}
+
+	if propagation != metav1.DeletePropagationForeground {
+		return nil
+	}
+
+	err = wait.Poll(time.Second, 20*time.Second, func() (done bool, err error) {
+		_, err = o.kubeClient.Dynamic().Resource(gvk).Namespace(namespace).Get(name, metav1.GetOptions{})
+		if errors.IsNotFound(err) {
+			return true, nil
+		}
+
+		return false, err
+	})
+
+	return err
+}
+
+func applyJQPatch(jqFilter string, obj *unstructured.Unstructured) (*unstructured.Unstructured, error) {
+	objBytes, err := obj.MarshalJSON()
+	if err != nil {
+		return nil, err
+	}
+
+	filterResult, err := jq.ApplyJqFilter(jqFilter, objBytes, app.JqLibraryPath)
+	if err != nil {
+		return nil, fmt.Errorf("failed to apply jqFilter:\n%sto Object:\n%s\n"+
+			"error: %s", jqFilter, obj, err)
+	}
+
+	var retObj = &unstructured.Unstructured{}
+	_, _, err = unstructured.UnstructuredJSONScheme.Decode([]byte(filterResult), nil, retObj)
+	if err != nil {
+		return nil, fmt.Errorf("failed to convert filterResult:\n%s\nto Unstructured Object\nerror: %s", filterResult, err)
+	}
+
+	return retObj, nil
+}
+
+func generateSubresources(subresource string) (ret []string) {
+	if subresource != "" {
+		ret = append(ret, subresource)
+	}
+
+	return
+}
diff --git a/pkg/kube/object_patch/types.go b/pkg/kube/object_patch/types.go
new file mode 100644
index 0000000..b171e6b
--- /dev/null
+++ b/pkg/kube/object_patch/types.go
@@ -0,0 +1,30 @@
+package object_patch
+
+type OperationSpec struct {
+	Operation   OperationType `json:"operation" yaml:"operation"`
+	ApiVersion  string        `json:"apiVersion,omitempty" yaml:"apiVersion,omitempty"`
+	Kind        string        `json:"kind,omitempty" yaml:"kind,omitempty"`
+	Namespace   string        `json:"namespace,omitempty" yaml:"namespace,omitempty"`
+	Name        string        `json:"name,omitempty" yaml:"name,omitempty"`
+	Subresource string        `json:"subresource,omitempty" yaml:"subresource,omitempty"`
+
+	Object     map[string]interface{} `json:"object,omitempty" yaml:"object,omitempty"`
+	JQFilter   string                 `json:"jqFilter,omitempty" yaml:"jqFilter,omitempty"`
+	MergePatch map[string]interface{} `json:"mergePatch,omitempty" yaml:"mergePatch,omitempty"`
+	JSONPatch  []interface{}          `json:"jsonPatch,omitempty" yaml:"jsonPatch,omitempty"`
+}
+
+type OperationType string
+
+const (
+	CreateOrUpdate OperationType = "CreateOrUpdate"
+	Create         OperationType = "Create"
+
+	Delete             OperationType = "Delete"
+	DeleteInBackground OperationType = "DeleteInBackground"
+	DeleteNonCascading OperationType = "DeleteNonCascading"
+
+	JQPatch    OperationType = "JQPatch"
+	MergePatch OperationType = "MergePatch"
+	JSONPatch  OperationType = "JSONPatch"
+)
diff --git a/pkg/kube/object_patch/validation.go b/pkg/kube/object_patch/validation.go
new file mode 100644
index 0000000..0b40c3b
--- /dev/null
+++ b/pkg/kube/object_patch/validation.go
@@ -0,0 +1,193 @@
+package object_patch
+
+import (
+	"encoding/json"
+	"fmt"
+
+	"github.com/go-openapi/spec"
+	"github.com/go-openapi/strfmt"
+	"github.com/go-openapi/swag"
+	"github.com/go-openapi/validate"
+	"github.com/hashicorp/go-multierror"
+	"sigs.k8s.io/yaml"
+)
+
+var Schemas = map[string]string{
+	"v0": `
+definitions:
+  common:
+    type: object
+    properties:
+      subresource:
+        type: string
+  create:
+    required:
+    - object
+    properties:
+      object:
+        type: object
+        additionalProperties: true
+        minProperties: 1
+  delete:
+    type: object
+    required:
+    - kind
+    - name
+    properties:
+      apiVersion:
+        type: string
+      kind:
+        type: string
+      name:
+        type: string
+  patch:
+    type: object
+    required:
+    - kind
+    - name
+    properties:
+      apiVersion:
+        type: string
+      kind:
+        type: string
+      name:
+        type: string
+
+type: object
+additionalProperties: false
+properties:
+  operation: {}
+  namespace: {}
+  subresource: {}
+  apiVersion: {}
+  kind: {}
+  name: {}
+  object: {}
+  jsonPatch: {}
+  jqFilter: {}
+  mergePatch: {}
+
+oneOf:
+- allOf:
+  - properties:
+      operation:
+        type: string
+        enum: ["Create", "CreateOrUpdate"]
+  - "$ref": "#/definitions/common"
+  - "$ref": "#/definitions/create"
+- allOf:
+  - properties:
+      operation:
+        type: string
+        enum: ["Delete", "DeleteInBackground", "DeleteNonCascading"]
+  - "$ref": "#/definitions/common"
+  - "$ref": "#/definitions/delete"
+- allOf:
+  - oneOf:
+    - properties:
+        operation:
+          type: string
+          enum: ["JQPatch"]
+        jqFilter:
+          type: string
+          minimum: 1
+    - properties:
+        operation:
+          type: string
+          enum: ["MergePatch"]
+        mergePatch:
+          type: object
+          minProperties: 1
+    - properties:
+        operation:
+          type: string
+          enum: ["JSONPatch"]
+        jsonPatch:
+          type: array
+          minItems: 1
+          items:
+          - type: object
+            required: ["op", "path", "value"]
+            properties:
+              op:
+                type: string
+                minLength: 1
+              path:
+                type: string
+                minLength: 1
+              value: {}
+  - "$ref": "#/definitions/common"
+  - "$ref": "#/definitions/patch"
+`,
+}
+
+var SchemasCache = map[string]*spec.Schema{}
+
+// GetSchema returns loaded schema.
+func GetSchema(name string) *spec.Schema {
+	if s, ok := SchemasCache[name]; ok {
+		return s
+	}
+	if _, ok := Schemas[name]; !ok {
+		return nil
+	}
+
+	// ignore error because load is guaranteed by tests
+	SchemasCache[name], _ = LoadSchema(name)
+	return SchemasCache[name]
+}
+
+// LoadSchema returns spec.Schema object loaded from yaml in Schemas map.
+func LoadSchema(name string) (*spec.Schema, error) {
+	yml, err := swag.BytesToYAMLDoc([]byte(Schemas[name]))
+	if err != nil {
+		return nil, fmt.Errorf("yaml unmarshal: %v", err)
+	}
+	d, err := swag.YAMLToJSON(yml)
+	if err != nil {
+		return nil, fmt.Errorf("yaml to json: %v", err)
+	}
+
+	s := new(spec.Schema)
+
+	if err := json.Unmarshal(d, s); err != nil {
+		return nil, fmt.Errorf("json unmarshal: %v", err)
+	}
+
+	err = spec.ExpandSchema(s, s, nil /*new(noopResCache)*/)
+	if err != nil {
+		return nil, fmt.Errorf("expand schema: %v", err)
+	}
+
+	return s, nil
+}
+
+// See https://github.com/kubernetes/apiextensions-apiserver/blob/1bb376f70aa2c6f2dec9a8c7f05384adbfac7fbb/pkg/apiserver/validation/validation.go#L47
+func ValidateOperationSpec(obj interface{}, s *spec.Schema, rootName string) (multiErr error) {
+	if s == nil {
+		return fmt.Errorf("validate kubernetes patch spec: schema is not provided")
+	}
+
+	validator := validate.NewSchemaValidator(s, nil, rootName, strfmt.Default)
+
+	result := validator.Validate(obj)
+	if result.IsValid() {
+		return nil
+	}
+
+	var allErrs = &multierror.Error{Errors: make([]error, 1)}
+	for _, err := range result.Errors {
+		allErrs = multierror.Append(allErrs, err)
+	}
+	// NOTE: no validation errors, but kubernetes patch spec is not valid!
+	if allErrs.Len() == 1 {
+		allErrs = multierror.Append(allErrs, fmt.Errorf("kubernetes patch spec is not valid"))
+	}
+
+	if allErrs.Len() > 1 {
+		yamlObj, _ := yaml.Marshal(obj)
+		allErrs.Errors[0] = fmt.Errorf("can't validate document:\n%s", yamlObj)
+	}
+
+	return allErrs
+}
diff --git a/pkg/shell-operator/operator.go b/pkg/shell-operator/operator.go
index 1225451..6ce7321 100644
--- a/pkg/shell-operator/operator.go
+++ b/pkg/shell-operator/operator.go
@@ -16,7 +16,9 @@ import (
 	. "github.com/flant/shell-operator/pkg/hook/binding_context"
 	. "github.com/flant/shell-operator/pkg/hook/task_metadata"
 	. "github.com/flant/shell-operator/pkg/hook/types"
+	"github.com/flant/shell-operator/pkg/kube/object_patch"
 	. "github.com/flant/shell-operator/pkg/kube_events_manager/types"
+	structured_logger "github.com/flant/shell-operator/pkg/utils/structured-logger"
 	. "github.com/flant/shell-operator/pkg/webhook/validating/types"
 
 	"github.com/flant/shell-operator/pkg/app"
@@ -33,7 +35,6 @@ import (
 	utils_file "github.com/flant/shell-operator/pkg/utils/file"
 	utils "github.com/flant/shell-operator/pkg/utils/labels"
 	"github.com/flant/shell-operator/pkg/utils/measure"
-	"github.com/flant/shell-operator/pkg/utils/structured-logger"
 	"github.com/flant/shell-operator/pkg/webhook/conversion"
 	"github.com/flant/shell-operator/pkg/webhook/validating"
 )
@@ -51,6 +52,7 @@ type ShellOperator struct {
 	// separate metric storage for hook metrics if separate listen port is configured
 	HookMetricStorage *metric_storage.MetricStorage
 	KubeClient        kube.KubernetesClient
+	ObjectPatcher     *object_patch.ObjectPatcher
 
 	ScheduleManager   schedule_manager.ScheduleManager
 	KubeEventsManager kube_events_manager.KubeEventsManager
@@ -187,6 +189,8 @@ func (op *ShellOperator) Init() (err error) {
 		}
 	}
 
+	op.ObjectPatcher = object_patch.NewObjectPatcher(op.KubeClient)
+
 	// Initialize the task queues set with the "main" queue.
 	op.TaskQueues = queue.NewTaskQueueSet()
 	op.TaskQueues.WithContext(op.ctx)
@@ -680,11 +684,29 @@ func (op *ShellOperator) TaskHandleHookRun(t task.Task) queue.TaskResult {
 			t.SetProp("conversionResponse", result.ConversionResponse)
 			taskLogEntry.Infof("ConversionResponse from hook: %s", result.ConversionResponse.Dump())
 		}
+
+		if len(result.KubernetesPatchBytes) > 0 {
+			var specs []object_patch.OperationSpec
+			specs, err = object_patch.ParseSpecs(result.KubernetesPatchBytes)
+			if err != nil {
+				goto Metrics
+			}
+
+			err = op.ObjectPatcher.GenerateFromJSONAndExecuteOperations(specs)
+			if err != nil {
+				goto Metrics
+			}
+		}
+
 		err = op.HookMetricStorage.SendBatch(result.Metrics, map[string]string{
 			"hook": hookMeta.HookName,
 		})
+		if err != nil {
+			goto Metrics
+		}
 	}
 
+Metrics:
 	success := 0.0
 	errors := 0.0
 	allowed := 0.0


Test patch:
diff --git a/pkg/hook/binding_context/binding_context_test.go b/pkg/hook/binding_context/binding_context_test.go
index 8b5524f..db65688 100644
--- a/pkg/hook/binding_context/binding_context_test.go
+++ b/pkg/hook/binding_context/binding_context_test.go
@@ -1,4 +1,4 @@
-package hook
+package binding_context
 
 import (
 	"testing"
diff --git a/pkg/kube/object_patch/patch_test.go b/pkg/kube/object_patch/patch_test.go
new file mode 100644
index 0000000..16b23dc
--- /dev/null
+++ b/pkg/kube/object_patch/patch_test.go
@@ -0,0 +1,72 @@
+package object_patch
+
+import (
+	"io/ioutil"
+	"testing"
+
+	. "github.com/onsi/gomega"
+)
+
+// TODO: Kubernetes API interaction is tested only in in the integration tests. Unit tests with the FakeClient would be faster.
+
+func mustReadFile(filePath string) []byte {
+	contents, err := ioutil.ReadFile(filePath)
+	if err != nil {
+		panic(err)
+	}
+
+	return contents
+}
+
+func TestParseSpecs(t *testing.T) {
+	g := NewWithT(t)
+
+	tests := []struct {
+		name         string
+		testFilePath string
+		wantErr      bool
+	}{
+		{
+			name:         "valid create",
+			testFilePath: "testdata/serialized_operations/valid_create.yaml",
+			wantErr:      false,
+		},
+		{
+			name:         "invalid create",
+			testFilePath: "testdata/serialized_operations/invalid_create.yaml",
+			wantErr:      true,
+		},
+		{
+			name:         "valid delete",
+			testFilePath: "testdata/serialized_operations/valid_delete.yaml",
+			wantErr:      false,
+		},
+		{
+			name:         "invalid delete",
+			testFilePath: "testdata/serialized_operations/invalid_delete.yaml",
+			wantErr:      true,
+		},
+		{
+			name:         "valid patch",
+			testFilePath: "testdata/serialized_operations/valid_patch.yaml",
+			wantErr:      false,
+		},
+		{
+			name:         "invalid patch",
+			testFilePath: "testdata/serialized_operations/invalid_patch.yaml",
+			wantErr:      true,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			testSpecs := mustReadFile(tt.testFilePath)
+
+			_, err := ParseSpecs(testSpecs)
+			if tt.wantErr {
+				g.Expect(err).To(HaveOccurred())
+			} else {
+				g.Expect(err).ToNot(HaveOccurred())
+			}
+		})
+	}
+}
diff --git a/pkg/kube/object_patch/testdata/serialized_operations/invalid_create.yaml b/pkg/kube/object_patch/testdata/serialized_operations/invalid_create.yaml
new file mode 100644
index 0000000..319e2f4
--- /dev/null
+++ b/pkg/kube/object_patch/testdata/serialized_operations/invalid_create.yaml
@@ -0,0 +1,8 @@
+---
+operation: Create
+namespace: default
+object:
+---
+operation: CreateOrUpdate
+namespace: default
+object:
\ No newline at end of file
diff --git a/pkg/kube/object_patch/testdata/serialized_operations/invalid_delete.yaml b/pkg/kube/object_patch/testdata/serialized_operations/invalid_delete.yaml
new file mode 100644
index 0000000..926f933
--- /dev/null
+++ b/pkg/kube/object_patch/testdata/serialized_operations/invalid_delete.yaml
@@ -0,0 +1,12 @@
+---
+operation: Delete
+kind: ConfigMap
+name: "test"
+---
+operation: DeleteInBackground
+apiversion: core/v1
+name: "test"
+---
+operation: DeleteNonCascading
+apiversion: core/v1
+kind: ConfigMap
diff --git a/pkg/kube/object_patch/testdata/serialized_operations/invalid_patch.yaml b/pkg/kube/object_patch/testdata/serialized_operations/invalid_patch.yaml
new file mode 100644
index 0000000..f7b042c
--- /dev/null
+++ b/pkg/kube/object_patch/testdata/serialized_operations/invalid_patch.yaml
@@ -0,0 +1,15 @@
+---
+operation: JQPatch
+apiversion: core/v1
+kind: ConfigMap
+name: "test"
+---
+operation: MergePatch
+apiversion: core/v1
+kind: ConfigMap
+name: "test"
+---
+operation: jsonPatch
+apiversion: core/v1
+kind: ConfigMap
+name: "test"
diff --git a/pkg/kube/object_patch/testdata/serialized_operations/valid_create.yaml b/pkg/kube/object_patch/testdata/serialized_operations/valid_create.yaml
new file mode 100644
index 0000000..8ed6904
--- /dev/null
+++ b/pkg/kube/object_patch/testdata/serialized_operations/valid_create.yaml
@@ -0,0 +1,16 @@
+---
+operation: Create
+namespace: default
+object:
+  apiVersion: core/v1
+  kind: ConfigMap
+  data:
+    test: test
+---
+operation: CreateOrUpdate
+namespace: default
+object:
+  apiVersion: core/v1
+  kind: ConfigMap
+  data:
+    test: test
\ No newline at end of file
diff --git a/pkg/kube/object_patch/testdata/serialized_operations/valid_delete.yaml b/pkg/kube/object_patch/testdata/serialized_operations/valid_delete.yaml
new file mode 100644
index 0000000..73ae807
--- /dev/null
+++ b/pkg/kube/object_patch/testdata/serialized_operations/valid_delete.yaml
@@ -0,0 +1,18 @@
+---
+operation: Delete
+apiVersion: core/v1
+kind: ConfigMap
+name: "test"
+namespace: "default"
+---
+operation: DeleteInBackground
+apiVersion: core/v1
+kind: ConfigMap
+name: "test"
+namespace: "default"
+---
+operation: DeleteNonCascading
+apiVersion: core/v1
+kind: ConfigMap
+name: "test"
+namespace: "default"
\ No newline at end of file
diff --git a/pkg/kube/object_patch/testdata/serialized_operations/valid_patch.yaml b/pkg/kube/object_patch/testdata/serialized_operations/valid_patch.yaml
new file mode 100644
index 0000000..3a45eb7
--- /dev/null
+++ b/pkg/kube/object_patch/testdata/serialized_operations/valid_patch.yaml
@@ -0,0 +1,27 @@
+---
+operation: JQPatch
+apiVersion: core/v1
+kind: ConfigMap
+name: "test"
+namespace: "default"
+jqFilter: '.data = {"test": test}'
+---
+operation: MergePatch
+apiVersion: core/v1
+kind: ConfigMap
+name: "test"
+namespace: "default"
+mergePatch:
+  data:
+    test: test
+---
+operation: JSONPatch
+apiVersion: core/v1
+kind: ConfigMap
+name: "test"
+namespace: "default"
+jsonPatch:
+- op: replace
+  path: /data
+  value:
+    test: test
diff --git a/pkg/kube/object_patch/validation_test.go b/pkg/kube/object_patch/validation_test.go
new file mode 100644
index 0000000..73d2858
--- /dev/null
+++ b/pkg/kube/object_patch/validation_test.go
@@ -0,0 +1,28 @@
+package object_patch
+
+import (
+	"testing"
+)
+
+func Test_GetSchema(t *testing.T) {
+	schemas := []string{"v0"}
+
+	for _, schema := range schemas {
+		s := GetSchema(schema)
+		if s == nil {
+			t.Fatalf("schema '%s' should not be nil", schema)
+		}
+	}
+}
+
+func Test_LoadSchema_From_Schemas(t *testing.T) {
+	for schemaVer := range Schemas {
+		s, err := LoadSchema(schemaVer)
+		if err != nil {
+			t.Fatalf("schema '%s' should load: %v", schemaVer, err)
+		}
+		if s == nil {
+			t.Fatalf("schema '%s' should not be nil: %v", schemaVer, err)
+		}
+	}
+}
diff --git a/pkg/shell-operator/operator_test.go b/pkg/shell-operator/operator_test.go
index 7387226..a4fb7c9 100644
--- a/pkg/shell-operator/operator_test.go
+++ b/pkg/shell-operator/operator_test.go
@@ -31,7 +31,7 @@ func Test_CombineBindingContext_MultipleHooks(t *testing.T) {
 			WithQueueName("test_multiple_hooks").
 			WithMetadata(HookMetadata{
 				HookName: "hook1.sh",
-				BindingContext: []hook.BindingContext{
+				BindingContext: []binding_context.BindingContext{
 					{
 						Binding: "kubernetes",
 						Type:    TypeEvent,
@@ -42,7 +42,7 @@ func Test_CombineBindingContext_MultipleHooks(t *testing.T) {
 			WithQueueName("test_multiple_hooks").
 			WithMetadata(HookMetadata{
 				HookName: "hook1.sh",
-				BindingContext: []hook.BindingContext{
+				BindingContext: []binding_context.BindingContext{
 					{
 						Binding: "kubernetes",
 						Type:    TypeEvent,
@@ -53,7 +53,7 @@ func Test_CombineBindingContext_MultipleHooks(t *testing.T) {
 			WithQueueName("test_multiple_hooks").
 			WithMetadata(HookMetadata{
 				HookName: "hook1.sh",
-				BindingContext: []hook.BindingContext{
+				BindingContext: []binding_context.BindingContext{
 					{
 						Binding: "schedule",
 					},
@@ -63,7 +63,7 @@ func Test_CombineBindingContext_MultipleHooks(t *testing.T) {
 			WithQueueName("test_multiple_hooks").
 			WithMetadata(HookMetadata{
 				HookName: "hook1.sh",
-				BindingContext: []hook.BindingContext{
+				BindingContext: []binding_context.BindingContext{
 					{
 						Binding: "kubernetes",
 						Type:    TypeEvent,
@@ -74,7 +74,7 @@ func Test_CombineBindingContext_MultipleHooks(t *testing.T) {
 			WithQueueName("test_multiple_hooks").
 			WithMetadata(HookMetadata{
 				HookName: "hook2.sh",
-				BindingContext: []hook.BindingContext{
+				BindingContext: []binding_context.BindingContext{
 					{
 						Binding: "kubernetes",
 						Type:    TypeEvent,
@@ -85,7 +85,7 @@ func Test_CombineBindingContext_MultipleHooks(t *testing.T) {
 			WithQueueName("test_multiple_hooks").
 			WithMetadata(HookMetadata{
 				HookName: "hook1.sh",
-				BindingContext: []hook.BindingContext{
+				BindingContext: []binding_context.BindingContext{
 					{
 						Binding: "kubernetes",
 						Type:    TypeEvent,
@@ -124,7 +124,7 @@ func Test_CombineBindingContext_Nil_On_NoCombine(t *testing.T) {
 			WithQueueName("test_no_combine").
 			WithMetadata(HookMetadata{
 				HookName: "hook1.sh",
-				BindingContext: []hook.BindingContext{
+				BindingContext: []binding_context.BindingContext{
 					{
 						Binding: "kubernetes",
 						Type:    TypeEvent,
@@ -135,7 +135,7 @@ func Test_CombineBindingContext_Nil_On_NoCombine(t *testing.T) {
 			WithQueueName("test_no_combine").
 			WithMetadata(HookMetadata{
 				HookName: "hook2.sh",
-				BindingContext: []hook.BindingContext{
+				BindingContext: []binding_context.BindingContext{
 					{
 						Binding: "kubernetes",
 						Type:    TypeEvent,
@@ -146,7 +146,7 @@ func Test_CombineBindingContext_Nil_On_NoCombine(t *testing.T) {
 			WithQueueName("test_no_combine").
 			WithMetadata(HookMetadata{
 				HookName: "hook3.sh",
-				BindingContext: []hook.BindingContext{
+				BindingContext: []binding_context.BindingContext{
 					{
 						Binding: "schedule",
 					},
@@ -178,7 +178,7 @@ func Test_CombineBindingContext_Group_Compaction(t *testing.T) {
 		}
 	})
 
-	bcMeta := hook.BindingContext{}.Metadata
+	bcMeta := binding_context.BindingContext{}.Metadata
 	bcMeta.Group = "pods"
 
 	var tasks = []task.Task{
@@ -187,7 +187,7 @@ func Test_CombineBindingContext_Group_Compaction(t *testing.T) {
 			WithQueueName("test_multiple_hooks").
 			WithMetadata(HookMetadata{
 				HookName: "hook1.sh",
-				BindingContext: []hook.BindingContext{
+				BindingContext: []binding_context.BindingContext{
 					{
 						Metadata: bcMeta,
 						Binding:  "kubernetes",
@@ -199,7 +199,7 @@ func Test_CombineBindingContext_Group_Compaction(t *testing.T) {
 			WithQueueName("test_multiple_hooks").
 			WithMetadata(HookMetadata{
 				HookName: "hook1.sh",
-				BindingContext: []hook.BindingContext{
+				BindingContext: []binding_context.BindingContext{
 					{
 						Metadata: bcMeta,
 						Binding:  "kubernetes",
@@ -211,7 +211,7 @@ func Test_CombineBindingContext_Group_Compaction(t *testing.T) {
 			WithQueueName("test_multiple_hooks").
 			WithMetadata(HookMetadata{
 				HookName: "hook1.sh",
-				BindingContext: []hook.BindingContext{
+				BindingContext: []binding_context.BindingContext{
 					{
 						Metadata: bcMeta,
 						Binding:  "schedule",
@@ -222,7 +222,7 @@ func Test_CombineBindingContext_Group_Compaction(t *testing.T) {
 			WithQueueName("test_multiple_hooks").
 			WithMetadata(HookMetadata{
 				HookName: "hook1.sh",
-				BindingContext: []hook.BindingContext{
+				BindingContext: []binding_context.BindingContext{
 					{
 						Binding: "kubernetes",
 						Type:    TypeEvent,
@@ -234,7 +234,7 @@ func Test_CombineBindingContext_Group_Compaction(t *testing.T) {
 			WithQueueName("test_multiple_hooks").
 			WithMetadata(HookMetadata{
 				HookName: "hook2.sh",
-				BindingContext: []hook.BindingContext{
+				BindingContext: []binding_context.BindingContext{
 					{
 						Binding: "kubernetes",
 						Type:    TypeEvent,
@@ -245,7 +245,7 @@ func Test_CombineBindingContext_Group_Compaction(t *testing.T) {
 			WithQueueName("test_multiple_hooks").
 			WithMetadata(HookMetadata{
 				HookName: "hook1.sh",
-				BindingContext: []hook.BindingContext{
+				BindingContext: []binding_context.BindingContext{
 					{
 						Binding: "kubernetes",
 						Type:    TypeEvent,
@@ -279,11 +279,11 @@ func Test_CombineBindingContext_Group_Type(t *testing.T) {
 		}
 	})
 
-	bcMeta := hook.BindingContext{}.Metadata
+	bcMeta := binding_context.BindingContext{}.Metadata
 	bcMeta.Group = "pods"
 	bcMeta.BindingType = types.OnKubernetesEvent
 
-	schMeta := hook.BindingContext{}.Metadata
+	schMeta := binding_context.BindingContext{}.Metadata
 	schMeta.Group = "pods"
 	schMeta.BindingType = types.Schedule
 
@@ -292,7 +292,7 @@ func Test_CombineBindingContext_Group_Type(t *testing.T) {
 			WithQueueName("test_multiple_hooks").
 			WithMetadata(HookMetadata{
 				HookName: "hook1.sh",
-				BindingContext: []hook.BindingContext{
+				BindingContext: []binding_context.BindingContext{
 					{
 						Metadata: bcMeta,
 						Binding:  "kubernetes",
@@ -304,7 +304,7 @@ func Test_CombineBindingContext_Group_Type(t *testing.T) {
 			WithQueueName("test_multiple_hooks").
 			WithMetadata(HookMetadata{
 				HookName: "hook1.sh",
-				BindingContext: []hook.BindingContext{
+				BindingContext: []binding_context.BindingContext{
 					{
 						Metadata: bcMeta,
 						Binding:  "kubernetes2",
@@ -316,7 +316,7 @@ func Test_CombineBindingContext_Group_Type(t *testing.T) {
 			WithQueueName("test_multiple_hooks").
 			WithMetadata(HookMetadata{
 				HookName: "hook1.sh",
-				BindingContext: []hook.BindingContext{
+				BindingContext: []binding_context.BindingContext{
 					{
 						Metadata: bcMeta,
 						Binding:  "schedule",
@@ -330,7 +330,7 @@ func Test_CombineBindingContext_Group_Type(t *testing.T) {
 			WithQueueName("test_multiple_hooks").
 			WithMetadata(HookMetadata{
 				HookName: "hook1.sh",
-				BindingContext: []hook.BindingContext{
+				BindingContext: []binding_context.BindingContext{
 					{
 						Binding: "kubernetes",
 						Type:    TypeEvent,
@@ -342,7 +342,7 @@ func Test_CombineBindingContext_Group_Type(t *testing.T) {
 			WithQueueName("test_multiple_hooks").
 			WithMetadata(HookMetadata{
 				HookName: "hook1.sh",
-				BindingContext: []hook.BindingContext{
+				BindingContext: []binding_context.BindingContext{
 					{
 						Metadata: schMeta,
 						Binding:  "schedule",
@@ -354,10 +354,10 @@ func Test_CombineBindingContext_Group_Type(t *testing.T) {
 			WithQueueName("test_multiple_hooks").
 			WithMetadata(HookMetadata{
 				HookName: "hook1.sh",
-				BindingContext: []hook.BindingContext{
+				BindingContext: []binding_context.BindingContext{
 					{
-						Metadata: func() hook.BindingContext {
-							bc := hook.BindingContext{}
+						Metadata: func() binding_context.BindingContext {
+							bc := binding_context.BindingContext{}
 							bc.Metadata.BindingType = types.Schedule
 							return bc
 						}().Metadata,
@@ -370,7 +370,7 @@ func Test_CombineBindingContext_Group_Type(t *testing.T) {
 			WithQueueName("test_multiple_hooks").
 			WithMetadata(HookMetadata{
 				HookName: "hook1.sh",
-				BindingContext: []hook.BindingContext{
+				BindingContext: []binding_context.BindingContext{
 					{
 						Metadata: bcMeta,
 						Binding:  "kubernetes",
@@ -383,7 +383,7 @@ func Test_CombineBindingContext_Group_Type(t *testing.T) {
 			WithQueueName("test_multiple_hooks").
 			WithMetadata(HookMetadata{
 				HookName: "hook1.sh",
-				BindingContext: []hook.BindingContext{
+				BindingContext: []binding_context.BindingContext{
 					{
 						Binding: "kubernetes",
 						Type:    TypeEvent,
@@ -394,7 +394,7 @@ func Test_CombineBindingContext_Group_Type(t *testing.T) {
 			WithQueueName("test_multiple_hooks").
 			WithMetadata(HookMetadata{
 				HookName: "hook1.sh",
-				BindingContext: []hook.BindingContext{
+				BindingContext: []binding_context.BindingContext{
 					{
 						Binding: "kubernetes",
 						Type:    TypeEvent,
diff --git a/test/integration/kubeclient/object_patch_test.go b/test/integration/kubeclient/object_patch_test.go
new file mode 100644
index 0000000..094cea6
--- /dev/null
+++ b/test/integration/kubeclient/object_patch_test.go
@@ -0,0 +1,226 @@
+package kubeclient
+
+import (
+	"encoding/json"
+	"testing"
+
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/runtime"
+	"sigs.k8s.io/yaml"
+
+	. "github.com/flant/shell-operator/test/integration/suite"
+)
+
+const (
+	creationNamespace = "creation"
+	deletionNamespace = "deletion"
+	patchNamespace    = "patch"
+)
+
+var testObject = &corev1.ConfigMap{
+	TypeMeta: metav1.TypeMeta{
+		Kind:       "ConfigMap",
+		APIVersion: "",
+	},
+	ObjectMeta: metav1.ObjectMeta{
+		Name: "test",
+	},
+	Data: map[string]string{
+		"firstField":  "test1",
+		"secondField": "test2",
+	},
+}
+
+func TestObjectPatch(t *testing.T) {
+	RunIntegrationSuite(t, "kube client suite", "kube-client-test")
+}
+
+var _ = Describe("Kubernetes API object patching", func() {
+	Context("creating an object", func() {
+		var (
+			testCM         *corev1.ConfigMap
+			unstructuredCM *unstructured.Unstructured
+		)
+
+		BeforeEach(func() {
+			testCM = testObject.DeepCopy()
+			testCM.Namespace = creationNamespace
+
+			var err error
+			unstructuredCM, err = generateUnstructured(testCM)
+			Expect(err).To(Succeed())
+
+			Expect(ensureNamespace(creationNamespace)).To(Succeed())
+			Expect(ensureTestObject(creationNamespace, testCM)).To(Succeed())
+		})
+
+		AfterEach(func() {
+			Expect(removeNamespace(creationNamespace))
+		})
+
+		It("should fail to Create() an object if it already exists", func() {
+			err := ObjectPatcher.CreateObject(unstructuredCM, "")
+			Expect(err).To(Not(Succeed()))
+		})
+
+		It("should should successfully CreateOrUpdate() an object even if it already exists", func() {
+			newTestCM := testCM.DeepCopy()
+			newTestCM.Data = map[string]string{
+				"firstField":  "test3",
+				"secondField": "test4",
+			}
+
+			unstructuredNewTestCM, err := generateUnstructured(newTestCM)
+			Expect(err).To(Succeed())
+
+			err = ObjectPatcher.CreateOrUpdateObject(unstructuredNewTestCM, "")
+			Expect(err).To(Succeed())
+
+			cm, err := KubeClient.CoreV1().ConfigMaps(creationNamespace).Get(newTestCM.Name, metav1.GetOptions{})
+			Expect(err).To(Succeed())
+			Expect(cm.Data).To(Equal(newTestCM.Data))
+		})
+
+		It("should successfully CreateOrUpdate() an object if id does not yet exist", func() {
+			separateTestCM := testCM.DeepCopy()
+			separateTestCM.Name = "separate-test"
+
+			unstructuredSeparateTestCM, err := generateUnstructured(separateTestCM)
+			Expect(err).To(Succeed())
+
+			err = ObjectPatcher.CreateOrUpdateObject(unstructuredSeparateTestCM, "")
+			Expect(err).To(Succeed())
+
+			_, err = KubeClient.CoreV1().ConfigMaps(creationNamespace).Get(separateTestCM.Name, metav1.GetOptions{})
+			Expect(err).To(Succeed())
+		})
+	})
+
+	Context("deleting an object", func() {
+		var (
+			testCM *corev1.ConfigMap
+		)
+
+		BeforeEach(func() {
+			testCM = testObject.DeepCopy()
+			testCM.Namespace = deletionNamespace
+
+			Expect(ensureNamespace(deletionNamespace)).To(Succeed())
+			Expect(ensureTestObject(deletionNamespace, testCM)).To(Succeed())
+		})
+
+		AfterEach(func() {
+			Expect(removeNamespace(deletionNamespace))
+		})
+
+		It("should successfully delete an object", func() {
+			err := ObjectPatcher.DeleteObjectInBackground(testCM.APIVersion, testCM.Kind, testCM.Namespace, testCM.Name, "")
+			Expect(err).Should(Succeed())
+
+			_, err = KubeClient.CoreV1().ConfigMaps(testCM.Namespace).Get(testCM.Name, metav1.GetOptions{})
+			Expect(errors.IsNotFound(err)).To(BeTrue())
+		})
+
+		It("should successfully delete an object if it doesn't exist", func() {
+			err := ObjectPatcher.DeleteObject(testCM.APIVersion, testCM.Kind, testCM.Namespace, testCM.Name, "")
+			Expect(err).Should(Succeed())
+		})
+	})
+
+	Context("patching an object", func() {
+		var (
+			testCM *corev1.ConfigMap
+		)
+
+		BeforeEach(func() {
+			testCM = testObject.DeepCopy()
+			testCM.Namespace = patchNamespace
+
+			Expect(ensureNamespace(patchNamespace)).To(Succeed())
+			Expect(ensureTestObject(patchNamespace, testCM)).To(Succeed())
+		})
+
+		AfterEach(func() {
+			Expect(removeNamespace(patchNamespace))
+		})
+
+		It("should successfully JQPatch an object", func() {
+			err := ObjectPatcher.JQPatchObject(`.data.firstField = "JQPatched"`, testCM.APIVersion, testCM.Kind, testCM.Namespace, testCM.Name, "")
+			Expect(err).Should(Succeed())
+
+			existingCM, err := KubeClient.CoreV1().ConfigMaps(testCM.Namespace).Get(testCM.Name, metav1.GetOptions{})
+			Expect(err).To(Succeed())
+			Expect(existingCM.Data["firstField"]).To(Equal("JQPatched"))
+		})
+
+		It("should successfully MergePatch an object", func() {
+			mergePatchYaml := `---
+data:
+  firstField: "mergePatched"
+`
+			var mergePatch map[string]interface{}
+			err := yaml.Unmarshal([]byte(mergePatchYaml), &mergePatch)
+			Expect(err).To(Succeed())
+
+			mergePatchJson, err := json.Marshal(mergePatch)
+			Expect(err).To(Succeed())
+
+			err = ObjectPatcher.MergePatchObject(mergePatchJson, testCM.APIVersion, testCM.Kind, testCM.Namespace, testCM.Name, "")
+			Expect(err).To(Succeed())
+
+			existingCM, err := KubeClient.CoreV1().ConfigMaps(testCM.Namespace).Get(testCM.Name, metav1.GetOptions{})
+			Expect(err).To(Succeed())
+			Expect(existingCM.Data["firstField"]).To(Equal("mergePatched"))
+		})
+
+		It("should successfully MergePatch an object", func() {
+			err := ObjectPatcher.JSONPatchObject([]byte(`[{ "op": "replace", "path": "/data/firstField", "value": "jsonPatched"}]`),
+				testCM.APIVersion, testCM.Kind, testCM.Namespace, testCM.Name, "")
+			Expect(err).To(Succeed())
+
+			existingCM, err := KubeClient.CoreV1().ConfigMaps(testCM.Namespace).Get(testCM.Name, metav1.GetOptions{})
+			Expect(err).To(Succeed())
+			Expect(existingCM.Data["firstField"]).To(Equal("jsonPatched"))
+		})
+	})
+})
+
+func ensureNamespace(name string) error {
+	ns := &corev1.Namespace{TypeMeta: metav1.TypeMeta{Kind: "Namespace"}, ObjectMeta: metav1.ObjectMeta{Name: name}}
+
+	unstructuredNS, err := generateUnstructured(ns)
+	if err != nil {
+		panic(err)
+	}
+
+	return ObjectPatcher.CreateOrUpdateObject(unstructuredNS, "")
+}
+
+func ensureTestObject(namespace string, obj interface{}) error {
+	unstructuredObj, err := generateUnstructured(obj)
+	if err != nil {
+		panic(err)
+	}
+
+	return ObjectPatcher.CreateOrUpdateObject(unstructuredObj, "")
+}
+
+func removeNamespace(name string) error {
+	return ObjectPatcher.DeleteObject("", "Namespace", "", name, "")
+}
+
+func generateUnstructured(obj interface{}) (*unstructured.Unstructured, error) {
+	var (
+		unstructuredCM = &unstructured.Unstructured{}
+		err            error
+	)
+
+	unstructuredCM.Object, err = runtime.DefaultUnstructuredConverter.ToUnstructured(obj)
+
+	return unstructuredCM, err
+}
diff --git a/test/integration/suite/run.go b/test/integration/suite/run.go
index 4a28462..2c6c599 100644
--- a/test/integration/suite/run.go
+++ b/test/integration/suite/run.go
@@ -9,14 +9,18 @@ import (
 	. "github.com/onsi/ginkgo"
 	. "github.com/onsi/gomega"
 
+	"github.com/flant/shell-operator/pkg/kube/object_patch"
 	. "github.com/flant/shell-operator/test/utils"
 
 	"github.com/flant/shell-operator/pkg/kube"
 )
 
-var ClusterName string
-var ContextName string
-var KubeClient kube.KubernetesClient
+var (
+	ClusterName   string
+	ContextName   string
+	KubeClient    kube.KubernetesClient
+	ObjectPatcher *object_patch.ObjectPatcher
+)
 
 func RunIntegrationSuite(t *testing.T, description string, clusterPrefix string) {
 	ClusterName = KindClusterName(clusterPrefix)
@@ -26,7 +30,7 @@ func RunIntegrationSuite(t *testing.T, description string, clusterPrefix string)
 }
 
 var _ = SynchronizedBeforeSuite(func() []byte {
-	Ω(KindCreateCluster(ClusterName)).Should(Succeed())
+	Expect(KindCreateCluster(ClusterName)).Should(Succeed())
 	fmt.Println(KindUseClusterMessage(ClusterName))
 	return []byte{}
 }, func([]byte) {
@@ -35,9 +39,11 @@ var _ = SynchronizedBeforeSuite(func() []byte {
 	KubeClient = kube.NewKubernetesClient()
 	KubeClient.WithContextName(ContextName)
 	err := KubeClient.Init()
-	Ω(err).ShouldNot(HaveOccurred())
+	Expect(err).ShouldNot(HaveOccurred())
+
+	ObjectPatcher = object_patch.NewObjectPatcher(KubeClient)
 })
 
 var _ = SynchronizedAfterSuite(func() {}, func() {
-	Ω(KindDeleteCluster(ClusterName)).Should(Succeed())
+	Expect(KindDeleteCluster(ClusterName)).Should(Succeed())
 })
diff --git a/test/utils/kind-k8s-cluster.go b/test/utils/kind-k8s-cluster.go
index b27c09a..27646fc 100644
--- a/test/utils/kind-k8s-cluster.go
+++ b/test/utils/kind-k8s-cluster.go
@@ -46,7 +46,7 @@ func KindCreateCluster(clusterName string) error {
 	); err != nil {
 		if errs := errors.Errors(err); errs != nil {
 			for _, problem := range errs {
-				fmt.Fprintf(os.Stderr, "KIND: %v", problem)
+				_, _ = fmt.Fprintf(os.Stderr, "KIND: %v", problem)
 			}
 			return errors.New("aborting due to invalid configuration")
 		}
@@ -93,7 +93,7 @@ func KindGetKubeContext(clusterName string) string {
 func KindClusterVersion() string {
 	k8sVer := os.Getenv("KIND_CLUSTER_VERSION")
 	if k8sVer == "" {
-		k8sVer = "1.15"
+		k8sVer = "1.20"
 	}
 
 	return k8sVer
@@ -108,12 +108,13 @@ func KindNodeImage() string {
 		return image
 	}
 	images := map[string]string{
-		"1.11": "kindest/node:v1.11.10",
-		"1.12": "kindest/node:v1.12.10",
-		"1.13": "kindest/node:v1.13.12",
 		"1.14": "kindest/node:v1.14.10",
-		"1.15": "kindest/node:v1.15.7",
-		"1.16": "kindest/node:v1.16.4",
+		"1.15": "kindest/node:v1.15.12",
+		"1.16": "kindest/node:v1.16.15",
+		"1.17": "kindest/node:v1.17.17",
+		"1.18": "kindest/node:v1.18.15",
+		"1.19": "kindest/node:v1.19.7",
+		"1.20": "kindest/node:v1.20.2",
 	}
 
 	return images[KindClusterVersion()]