Repository: helm/helm (go)

Issue:
--take-ownership does not adopt custom resources
If a resource exists in the cluster and is to be adopted by `helm install --take-ownership`, it is left unchanged while helm reports the installation to have succeeded.

# Reproduce

0. Helm version v3.17.1+g980d8ac

1. Install any CRD

```yaml
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: datas.crd.com
spec:
  group: crd.com
  scope: Namespaced
  names:
    plural: datas
    singular: data
    kind: Data
  versions:
  - name: v1
    served: true
    storage: true
    schema:
      openAPIV3Schema:
        type: object
        x-kubernetes-preserve-unknown-fields: true
```

2. Apply a custom resource onto the cluster.

```yaml
kind: Data
apiVersion: crd.com/v1
metadata:
  name: crd-test
  namespace: kube-system
  labels:
    test: adopt
spec:
  color: blue
  size: large
```

3. `helm install --take-ownership` a chart that contains a different version of the custom resource.

```yaml
kind: Data
apiVersion: crd.com/v1
metadata:
  name: crd-test
  namespace: kube-system
  labels:
    test: adopt
spec:
  weight: heavy
```

4. Notice that the custom resource has not changed at all (still at initial state) but helm reported the operation to have succeeded, including the creation of a helm release.

```
helm install foo ./foo --take-ownership 
NAME: foo
LAST DEPLOYED: Wed Mar  5 17:10:16 2025
NAMESPACE: default
STATUS: deployed
REVISION: 1
TEST SUITE: None
```

# Suggested Solution

Transparently use three-way merge for `--take-ownership`.

Pull request:
Fix --take-ownership for custom resources - closes #30622
If a resource exists in the cluster and is to be adopted by helm install --take-ownership, it is left unchanged while helm reports the installation to have succeeded.

This is due to CRs and CRDs being merged without three-way-merge, which results in an empty patch.

By using a three-way-merge transparently when --take-ownership is used, helm behaves as expected without breaking previous behavior.

Fixes #30622

<!--  Thanks for sending a pull request!  Here are some tips for you:
1. Make sure to read the Contributing Guide before submitting your PR: https://github.com/helm/helm/blob/main/CONTRIBUTING.md
2. If this PR closes another issue, add 'closes #<issue number>' somewhere in the PR summary. GitHub will automatically close that issue when this PR gets merged. Alternatively, adding 'refs #<issue number>' will not close the issue, but help provide the reviewer more context.-->

**What this PR does / why we need it**:

**Special notes for your reviewer**:

**If applicable**:
- [ ] this PR contains user facing changes (the `docs needed` label should be applied if so)
  --take-ownership does not appear to be documented at all, so perhaps yes from that perspective, but no from the perspective of this PR.
- [x] this PR contains unit tests
- [ ] this PR has been tested for backwards compatibility
  The changes in this PR are meant to not change any existing behavior, except for how helm behaves when called with `--take-ownership`.


Patch:
diff --git a/pkg/action/install.go b/pkg/action/install.go
index 25c48c762..b7d6a81d0 100644
--- a/pkg/action/install.go
+++ b/pkg/action/install.go
@@ -466,7 +466,11 @@ func (i *Install) performInstall(rel *release.Release, toBeAdopted kube.Resource
 	if len(toBeAdopted) == 0 && len(resources) > 0 {
 		_, err = i.cfg.KubeClient.Create(resources)
 	} else if len(resources) > 0 {
-		_, err = i.cfg.KubeClient.Update(toBeAdopted, resources, i.Force)
+		if i.TakeOwnership {
+			_, err = i.cfg.KubeClient.(kube.InterfaceThreeWayMerge).UpdateThreeWayMerge(toBeAdopted, resources, i.Force)
+		} else {
+			_, err = i.cfg.KubeClient.Update(toBeAdopted, resources, i.Force)
+		}
 	}
 	if err != nil {
 		return rel, err
diff --git a/pkg/kube/client.go b/pkg/kube/client.go
index beac9ac7a..ff0f05e88 100644
--- a/pkg/kube/client.go
+++ b/pkg/kube/client.go
@@ -44,6 +44,8 @@ import (
 	metav1beta1 "k8s.io/apimachinery/pkg/apis/meta/v1beta1"
 	"k8s.io/apimachinery/pkg/labels"
 	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/apimachinery/pkg/util/jsonmergepatch"
+	"k8s.io/apimachinery/pkg/util/mergepatch"
 	"k8s.io/apimachinery/pkg/util/strategicpatch"
 	"k8s.io/cli-runtime/pkg/genericclioptions"
 	"k8s.io/cli-runtime/pkg/resource"
@@ -395,14 +397,7 @@ func (c *Client) BuildTable(reader io.Reader, validate bool) (ResourceList, erro
 	return result, scrubValidationError(err)
 }
 
-// Update takes the current list of objects and target list of objects and
-// creates resources that don't already exist, updates resources that have been
-// modified in the target configuration, and deletes resources from the current
-// configuration that are not present in the target configuration. If an error
-// occurs, a Result will still be returned with the error, containing all
-// resource updates, creations, and deletions that were attempted. These can be
-// used for cleanup or other logging purposes.
-func (c *Client) Update(original, target ResourceList, force bool) (*Result, error) {
+func (c *Client) update(original, target ResourceList, force, threeWayMerge bool) (*Result, error) {
 	updateErrors := []string{}
 	res := &Result{}
 
@@ -437,7 +432,7 @@ func (c *Client) Update(original, target ResourceList, force bool) (*Result, err
 			return errors.Errorf("no %s with the name %q found", kind, info.Name)
 		}
 
-		if err := updateResource(c, info, originalInfo.Object, force); err != nil {
+		if err := updateResource(c, info, originalInfo.Object, force, threeWayMerge); err != nil {
 			slog.Debug("error updating the resource", "namespace", info.Namespace, "name", info.Name, "kind", info.Mapping.GroupVersionKind.Kind, slog.Any("error", err))
 			updateErrors = append(updateErrors, err.Error())
 		}
@@ -478,6 +473,31 @@ func (c *Client) Update(original, target ResourceList, force bool) (*Result, err
 	return res, nil
 }
 
+// Update takes the current list of objects and target list of objects and
+// creates resources that don't already exist, updates resources that have been
+// modified in the target configuration, and deletes resources from the current
+// configuration that are not present in the target configuration. If an error
+// occurs, a Result will still be returned with the error, containing all
+// resource updates, creations, and deletions that were attempted. These can be
+// used for cleanup or other logging purposes.
+//
+// The difference to Update is that UpdateThreeWayMerge does a three-way-merge
+// for unstructured objects.
+func (c *Client) UpdateThreeWayMerge(original, target ResourceList, force bool) (*Result, error) {
+	return c.update(original, target, force, true)
+}
+
+// Update takes the current list of objects and target list of objects and
+// creates resources that don't already exist, updates resources that have been
+// modified in the target configuration, and deletes resources from the current
+// configuration that are not present in the target configuration. If an error
+// occurs, a Result will still be returned with the error, containing all
+// resource updates, creations, and deletions that were attempted. These can be
+// used for cleanup or other logging purposes.
+func (c *Client) Update(original, target ResourceList, force bool) (*Result, error) {
+	return c.update(original, target, force, false)
+}
+
 // Delete deletes Kubernetes resources specified in the resources list with
 // background cascade deletion. It will attempt to delete all resources even
 // if one or more fail and collect any errors. All successfully deleted items
@@ -587,7 +607,7 @@ func deleteResource(info *resource.Info, policy metav1.DeletionPropagation) erro
 		})
 }
 
-func createPatch(target *resource.Info, current runtime.Object) ([]byte, types.PatchType, error) {
+func createPatch(target *resource.Info, current runtime.Object, threeWayMergeForUnstructured bool) ([]byte, types.PatchType, error) {
 	oldData, err := json.Marshal(current)
 	if err != nil {
 		return nil, types.StrategicMergePatchType, errors.Wrap(err, "serializing current configuration")
@@ -615,7 +635,7 @@ func createPatch(target *resource.Info, current runtime.Object) ([]byte, types.P
 
 	// Unstructured objects, such as CRDs, may not have a not registered error
 	// returned from ConvertToVersion. Anything that's unstructured should
-	// use the jsonpatch.CreateMergePatch. Strategic Merge Patch is not supported
+	// use generic JSON merge patch. Strategic Merge Patch is not supported
 	// on objects like CRDs.
 	_, isUnstructured := versionedObject.(runtime.Unstructured)
 
@@ -623,6 +643,19 @@ func createPatch(target *resource.Info, current runtime.Object) ([]byte, types.P
 	_, isCRD := versionedObject.(*apiextv1beta1.CustomResourceDefinition)
 
 	if isUnstructured || isCRD {
+		if threeWayMergeForUnstructured {
+			// from https://github.com/kubernetes/kubectl/blob/b83b2ec7d15f286720bccf7872b5c72372cb8e80/pkg/cmd/apply/patcher.go#L129
+			preconditions := []mergepatch.PreconditionFunc{
+				mergepatch.RequireKeyUnchanged("apiVersion"),
+				mergepatch.RequireKeyUnchanged("kind"),
+				mergepatch.RequireMetadataKeyUnchanged("name"),
+			}
+			patch, err := jsonmergepatch.CreateThreeWayJSONMergePatch(oldData, newData, currentData, preconditions...)
+			if err != nil && mergepatch.IsPreconditionFailed(err) {
+				err = fmt.Errorf("%w: at least one field was changed: apiVersion, kind or name", err)
+			}
+			return patch, types.MergePatchType, err
+		}
 		// fall back to generic JSON merge patch
 		patch, err := jsonpatch.CreateMergePatch(oldData, newData)
 		return patch, types.MergePatchType, err
@@ -637,7 +670,7 @@ func createPatch(target *resource.Info, current runtime.Object) ([]byte, types.P
 	return patch, types.StrategicMergePatchType, err
 }
 
-func updateResource(_ *Client, target *resource.Info, currentObj runtime.Object, force bool) error {
+func updateResource(_ *Client, target *resource.Info, currentObj runtime.Object, force, threeWayMergeForUnstructured bool) error {
 	var (
 		obj    runtime.Object
 		helper = resource.NewHelper(target.Client, target.Mapping).WithFieldManager(getManagedFieldsManager())
@@ -653,7 +686,7 @@ func updateResource(_ *Client, target *resource.Info, currentObj runtime.Object,
 		}
 		slog.Debug("replace succeeded", "name", target.Name, "initialKind", currentObj.GetObjectKind().GroupVersionKind().Kind, "kind", kind)
 	} else {
-		patch, patchType, err := createPatch(target, currentObj)
+		patch, patchType, err := createPatch(target, currentObj, threeWayMergeForUnstructured)
 		if err != nil {
 			return errors.Wrap(err, "failed to create patch")
 		}
diff --git a/pkg/kube/fake/fake.go b/pkg/kube/fake/fake.go
index f868afa1a..a543a0f73 100644
--- a/pkg/kube/fake/fake.go
+++ b/pkg/kube/fake/fake.go
@@ -41,6 +41,7 @@ type FailingKubeClient struct {
 	BuildError                 error
 	BuildTableError            error
 	BuildDummy                 bool
+	DummyResources             kube.ResourceList
 	BuildUnstructuredError     error
 	WaitError                  error
 	WaitForDeleteError         error
@@ -123,11 +124,22 @@ func (f *FailingKubeClient) Update(r, modified kube.ResourceList, ignoreMe bool)
 	return f.PrintingKubeClient.Update(r, modified, ignoreMe)
 }
 
+// Update returns the configured error if set or prints
+func (f *FailingKubeClient) UpdateThreeWayMerge(r, modified kube.ResourceList, ignoreMe bool) (*kube.Result, error) {
+	if f.UpdateError != nil {
+		return &kube.Result{}, f.UpdateError
+	}
+	return f.PrintingKubeClient.Update(r, modified, ignoreMe)
+}
+
 // Build returns the configured error if set or prints
 func (f *FailingKubeClient) Build(r io.Reader, _ bool) (kube.ResourceList, error) {
 	if f.BuildError != nil {
 		return []*resource.Info{}, f.BuildError
 	}
+	if f.DummyResources != nil {
+		return f.DummyResources, nil
+	}
 	if f.BuildDummy {
 		return createDummyResourceList(), nil
 	}
diff --git a/pkg/kube/interface.go b/pkg/kube/interface.go
index f68367dcd..6b945088e 100644
--- a/pkg/kube/interface.go
+++ b/pkg/kube/interface.go
@@ -53,6 +53,13 @@ type Interface interface {
 	GetWaiter(ws WaitStrategy) (Waiter, error)
 }
 
+// InterfaceThreeWayMerge was introduced to avoid breaking backwards compatibility for Interface implementers.
+//
+// TODO Helm 4: Remove InterfaceThreeWayMerge and integrate its method(s) into the Interface.
+type InterfaceThreeWayMerge interface {
+	UpdateThreeWayMerge(original, target ResourceList, force bool) (*Result, error)
+}
+
 // Waiter defines methods related to waiting for resource states.
 type Waiter interface {
 	// Wait waits up to the given timeout for the specified resources to be ready.
@@ -118,6 +125,7 @@ type InterfaceResources interface {
 }
 
 var _ Interface = (*Client)(nil)
+var _ InterfaceThreeWayMerge = (*Client)(nil)
 var _ InterfaceLogs = (*Client)(nil)
 var _ InterfaceDeletionPropagation = (*Client)(nil)
 var _ InterfaceResources = (*Client)(nil)


Test patch:
diff --git a/pkg/action/action_test.go b/pkg/action/action_test.go
index 4a0691afb..f808163fb 100644
--- a/pkg/action/action_test.go
+++ b/pkg/action/action_test.go
@@ -28,6 +28,7 @@ import (
 	"helm.sh/helm/v4/internal/logging"
 	chart "helm.sh/helm/v4/pkg/chart/v2"
 	chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
+	"helm.sh/helm/v4/pkg/kube"
 	kubefake "helm.sh/helm/v4/pkg/kube/fake"
 	"helm.sh/helm/v4/pkg/registry"
 	release "helm.sh/helm/v4/pkg/release/v1"
@@ -39,6 +40,10 @@ import (
 var verbose = flag.Bool("test.log", false, "enable test logging (debug by default)")
 
 func actionConfigFixture(t *testing.T) *Configuration {
+	return actionConfigFixtureWithDummyResources(t, nil)
+}
+
+func actionConfigFixtureWithDummyResources(t *testing.T, dummyResources kube.ResourceList) *Configuration {
 	t.Helper()
 
 	logger := logging.NewLogger(func() bool {
@@ -53,7 +58,7 @@ func actionConfigFixture(t *testing.T) *Configuration {
 
 	return &Configuration{
 		Releases:       storage.Init(driver.NewMemory()),
-		KubeClient:     &kubefake.FailingKubeClient{PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard}},
+		KubeClient:     &kubefake.FailingKubeClient{PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard}, DummyResources: dummyResources},
 		Capabilities:   chartutil.DefaultCapabilities,
 		RegistryClient: registryClient,
 	}
diff --git a/pkg/action/install_test.go b/pkg/action/install_test.go
index b2d147188..56204651f 100644
--- a/pkg/action/install_test.go
+++ b/pkg/action/install_test.go
@@ -21,6 +21,7 @@ import (
 	"context"
 	"fmt"
 	"io"
+	"net/http"
 	"os"
 	"path/filepath"
 	"regexp"
@@ -31,6 +32,14 @@ import (
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
+	appsv1 "k8s.io/api/apps/v1"
+	"k8s.io/apimachinery/pkg/api/meta"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	kuberuntime "k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/cli-runtime/pkg/resource"
+	"k8s.io/client-go/kubernetes/scheme"
+	"k8s.io/client-go/rest/fake"
 
 	"helm.sh/helm/v4/internal/test"
 	chart "helm.sh/helm/v4/pkg/chart/v2"
@@ -48,6 +57,62 @@ type nameTemplateTestCase struct {
 	expectedErrorStr string
 }
 
+func createDummyResourceList(owned bool) kube.ResourceList {
+	obj := &appsv1.Deployment{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "dummyName",
+			Namespace: "spaced",
+		},
+	}
+
+	if owned {
+		obj.Labels = map[string]string{
+			"app.kubernetes.io/managed-by": "Helm",
+		}
+		obj.Annotations = map[string]string{
+			"meta.helm.sh/release-name":      "test-install-release",
+			"meta.helm.sh/release-namespace": "spaced",
+		}
+	}
+
+	resInfo := resource.Info{
+		Name:      "dummyName",
+		Namespace: "spaced",
+		Mapping: &meta.RESTMapping{
+			Resource:         schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployment"},
+			GroupVersionKind: schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"},
+			Scope:            meta.RESTScopeNamespace,
+		},
+		Object: obj,
+	}
+	body := io.NopCloser(bytes.NewReader([]byte(kuberuntime.EncodeOrDie(appsv1Codec, obj))))
+
+	resInfo.Client = &fake.RESTClient{
+		GroupVersion:         schema.GroupVersion{Group: "apps", Version: "v1"},
+		NegotiatedSerializer: scheme.Codecs.WithoutConversion(),
+		Client: fake.CreateHTTPClient(func(_ *http.Request) (*http.Response, error) {
+			header := http.Header{}
+			header.Set("Content-Type", kuberuntime.ContentTypeJSON)
+			return &http.Response{
+				StatusCode: http.StatusOK,
+				Header:     header,
+				Body:       body,
+			}, nil
+		}),
+	}
+	var resourceList kube.ResourceList
+	resourceList.Append(&resInfo)
+	return resourceList
+}
+
+func installActionWithConfig(config *Configuration) *Install {
+	instAction := NewInstall(config)
+	instAction.Namespace = "spaced"
+	instAction.ReleaseName = "test-install-release"
+
+	return instAction
+}
+
 func installAction(t *testing.T) *Install {
 	config := actionConfigFixture(t)
 	instAction := NewInstall(config)
@@ -93,6 +158,61 @@ func TestInstallRelease(t *testing.T) {
 	is.Equal(lastRelease.Info.Status, release.StatusDeployed)
 }
 
+func TestInstallReleaseWithTakeOwnership_ResourceNotOwned(t *testing.T) {
+	// This test will test checking ownership of a resource
+	// returned by the fake client. If the resource is not
+	// owned by the chart, ownership is taken.
+	// To verify ownership has been taken, the fake client
+	// needs to store state which is a bigger rewrite.
+	// TODO: Ensure fake kube client stores state. Maybe using
+	// "k8s.io/client-go/kubernetes/fake" could be sufficient? i.e
+	// "Client{Namespace: namespace, kubeClient: k8sfake.NewClientset()}"
+
+	is := assert.New(t)
+
+	// Resource list from cluster is NOT owned by helm chart
+	config := actionConfigFixtureWithDummyResources(t, createDummyResourceList(false))
+	instAction := installActionWithConfig(config)
+	instAction.TakeOwnership = true
+	res, err := instAction.Run(buildChart(), nil)
+	if err != nil {
+		t.Fatalf("Failed install: %s", err)
+	}
+
+	rel, err := instAction.cfg.Releases.Get(res.Name, res.Version)
+	is.NoError(err)
+
+	is.Equal(rel.Info.Description, "Install complete")
+}
+
+func TestInstallReleaseWithTakeOwnership_ResourceOwned(t *testing.T) {
+	is := assert.New(t)
+
+	// Resource list from cluster is owned by helm chart
+	config := actionConfigFixtureWithDummyResources(t, createDummyResourceList(true))
+	instAction := installActionWithConfig(config)
+	instAction.TakeOwnership = false
+	res, err := instAction.Run(buildChart(), nil)
+	if err != nil {
+		t.Fatalf("Failed install: %s", err)
+	}
+	rel, err := instAction.cfg.Releases.Get(res.Name, res.Version)
+	is.NoError(err)
+
+	is.Equal(rel.Info.Description, "Install complete")
+}
+
+func TestInstallReleaseWithTakeOwnership_ResourceOwnedNoFlag(t *testing.T) {
+	is := assert.New(t)
+
+	// Resource list from cluster is NOT owned by helm chart
+	config := actionConfigFixtureWithDummyResources(t, createDummyResourceList(false))
+	instAction := installActionWithConfig(config)
+	_, err := instAction.Run(buildChart(), nil)
+	is.Error(err)
+	is.Contains(err.Error(), "Unable to continue with install")
+}
+
 func TestInstallReleaseWithValues(t *testing.T) {
 	is := assert.New(t)
 	instAction := installAction(t)
diff --git a/pkg/kube/client_test.go b/pkg/kube/client_test.go
index c755b490c..56c7eebc9 100644
--- a/pkg/kube/client_test.go
+++ b/pkg/kube/client_test.go
@@ -27,8 +27,13 @@ import (
 	"github.com/stretchr/testify/assert"
 
 	v1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/api/meta"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	jsonserializer "k8s.io/apimachinery/pkg/runtime/serializer/json"
+	"k8s.io/apimachinery/pkg/types"
 	"k8s.io/cli-runtime/pkg/resource"
 	k8sfake "k8s.io/client-go/kubernetes/fake"
 	"k8s.io/client-go/kubernetes/scheme"
@@ -207,7 +212,7 @@ func TestCreate(t *testing.T) {
 	})
 }
 
-func TestUpdate(t *testing.T) {
+func testUpdate(t *testing.T, threeWayMerge bool) {
 	listA := newPodList("starfish", "otter", "squid")
 	listB := newPodList("starfish", "otter", "dolphin")
 	listC := newPodList("starfish", "otter", "dolphin")
@@ -278,7 +283,12 @@ func TestUpdate(t *testing.T) {
 		t.Fatal(err)
 	}
 
-	result, err := c.Update(first, second, false)
+	var result *Result
+	if threeWayMerge {
+		result, err = c.UpdateThreeWayMerge(first, second, false)
+	} else {
+		result, err = c.Update(first, second, false)
+	}
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -327,6 +337,14 @@ func TestUpdate(t *testing.T) {
 	}
 }
 
+func TestUpdate(t *testing.T) {
+	testUpdate(t, false)
+}
+
+func TestUpdateThreeWayMerge(t *testing.T) {
+	testUpdate(t, true)
+}
+
 func TestBuild(t *testing.T) {
 	tests := []struct {
 		name      string
@@ -912,3 +930,150 @@ spec:
 
 var resourceQuotaConflict = []byte(`
 {"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Operation cannot be fulfilled on resourcequotas \"quota\": the object has been modified; please apply your changes to the latest version and try again","reason":"Conflict","details":{"name":"quota","kind":"resourcequotas"},"code":409}`)
+
+type createPatchTestCase struct {
+	name string
+
+	// The target state.
+	target *unstructured.Unstructured
+	// The current state as it exists in the release.
+	current *unstructured.Unstructured
+	// The actual state as it exists in the cluster.
+	actual *unstructured.Unstructured
+
+	threeWayMergeForUnstructured bool
+	// The patch is supposed to transfer the current state to the target state,
+	// thereby preserving the actual state, wherever possible.
+	expectedPatch     string
+	expectedPatchType types.PatchType
+}
+
+func (c createPatchTestCase) run(t *testing.T) {
+	scheme := runtime.NewScheme()
+	v1.AddToScheme(scheme)
+	encoder := jsonserializer.NewSerializerWithOptions(
+		jsonserializer.DefaultMetaFactory, scheme, scheme, jsonserializer.SerializerOptions{
+			Yaml: false, Pretty: false, Strict: true,
+		},
+	)
+	objBody := func(obj runtime.Object) io.ReadCloser {
+		return io.NopCloser(bytes.NewReader([]byte(runtime.EncodeOrDie(encoder, obj))))
+	}
+	header := make(http.Header)
+	header.Set("Content-Type", runtime.ContentTypeJSON)
+	restClient := &fake.RESTClient{
+		NegotiatedSerializer: unstructuredSerializer,
+		Resp: &http.Response{
+			StatusCode: 200,
+			Body:       objBody(c.actual),
+			Header:     header,
+		},
+	}
+
+	targetInfo := &resource.Info{
+		Client:    restClient,
+		Namespace: "default",
+		Name:      "test-obj",
+		Object:    c.target,
+		Mapping: &meta.RESTMapping{
+			Resource: schema.GroupVersionResource{
+				Group:    "crd.com",
+				Version:  "v1",
+				Resource: "datas",
+			},
+			Scope: meta.RESTScopeNamespace,
+		},
+	}
+
+	patch, patchType, err := createPatch(targetInfo, c.current, c.threeWayMergeForUnstructured)
+	if err != nil {
+		t.Fatalf("Failed to create patch: %v", err)
+	}
+
+	if c.expectedPatch != string(patch) {
+		t.Errorf("Unexpected patch.\nTarget:\n%s\nCurrent:\n%s\nActual:\n%s\n\nExpected:\n%s\nGot:\n%s",
+			c.target,
+			c.current,
+			c.actual,
+			c.expectedPatch,
+			string(patch),
+		)
+	}
+
+	if patchType != types.MergePatchType {
+		t.Errorf("Expected patch type %s, got %s", types.MergePatchType, patchType)
+	}
+}
+
+func newTestCustomResourceData(metadata map[string]string, spec map[string]interface{}) *unstructured.Unstructured {
+	if metadata == nil {
+		metadata = make(map[string]string)
+	}
+	if _, ok := metadata["name"]; !ok {
+		metadata["name"] = "test-obj"
+	}
+	if _, ok := metadata["namespace"]; !ok {
+		metadata["namespace"] = "default"
+	}
+	o := map[string]interface{}{
+		"apiVersion": "crd.com/v1",
+		"kind":       "Data",
+		"metadata":   metadata,
+	}
+	if len(spec) > 0 {
+		o["spec"] = spec
+	}
+	return &unstructured.Unstructured{
+		Object: o,
+	}
+}
+
+func TestCreatePatchCustomResourceMetadata(t *testing.T) {
+	target := newTestCustomResourceData(map[string]string{
+		"meta.helm.sh/release-name":      "foo-simple",
+		"meta.helm.sh/release-namespace": "default",
+		"objectset.rio.cattle.io/id":     "default-foo-simple",
+	}, nil)
+	testCase := createPatchTestCase{
+		name:    "take ownership of resource",
+		target:  target,
+		current: target,
+		actual: newTestCustomResourceData(nil, map[string]interface{}{
+			"color": "red",
+		}),
+		threeWayMergeForUnstructured: true,
+		expectedPatch:                `{"metadata":{"meta.helm.sh/release-name":"foo-simple","meta.helm.sh/release-namespace":"default","objectset.rio.cattle.io/id":"default-foo-simple"}}`,
+		expectedPatchType:            types.MergePatchType,
+	}
+	t.Run(testCase.name, testCase.run)
+
+	// Previous behavior.
+	testCase.threeWayMergeForUnstructured = false
+	testCase.expectedPatch = `{}`
+	t.Run(testCase.name, testCase.run)
+}
+
+func TestCreatePatchCustomResourceSpec(t *testing.T) {
+	target := newTestCustomResourceData(nil, map[string]interface{}{
+		"color": "red",
+		"size":  "large",
+	})
+	testCase := createPatchTestCase{
+		name:    "merge with spec of existing custom resource",
+		target:  target,
+		current: target,
+		actual: newTestCustomResourceData(nil, map[string]interface{}{
+			"color":  "red",
+			"weight": "heavy",
+		}),
+		threeWayMergeForUnstructured: true,
+		expectedPatch:                `{"spec":{"size":"large"}}`,
+		expectedPatchType:            types.MergePatchType,
+	}
+	t.Run(testCase.name, testCase.run)
+
+	// Previous behavior.
+	testCase.threeWayMergeForUnstructured = false
+	testCase.expectedPatch = `{}`
+	t.Run(testCase.name, testCase.run)
+}