From 95670111b33c90a0d4e86d431e86f290b5209868 Mon Sep 17 00:00:00 2001 From: Tarun Gupta Akirala Date: Thu, 23 Apr 2026 02:03:42 -0700 Subject: [PATCH 1/2] fix: use forceConflict field in install cmd Signed-off-by: Tarun Gupta Akirala --- pkg/action/install.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/action/install.go b/pkg/action/install.go index 580b8a0cb..923fae7d8 100644 --- a/pkg/action/install.go +++ b/pkg/action/install.go @@ -518,7 +518,7 @@ func (i *Install) performInstall(rel *release.Release, toBeAdopted kube.Resource if len(toBeAdopted) == 0 && len(resources) > 0 { _, err = i.cfg.KubeClient.Create( resources, - kube.ClientCreateOptionServerSideApply(i.ServerSideApply, false)) + kube.ClientCreateOptionServerSideApply(i.ServerSideApply, i.ForceConflicts)) } else if len(resources) > 0 { updateThreeWayMergeForUnstructured := i.TakeOwnership && !i.ServerSideApply // Use three-way merge when taking ownership (and not using server-side apply) _, err = i.cfg.KubeClient.Update( From 9bbb1cc71a0015e0b95514e47f9e061651ac089a Mon Sep 17 00:00:00 2001 From: Tarun Gupta Akirala Date: Thu, 23 Apr 2026 02:07:50 -0700 Subject: [PATCH 2/2] test: verify ForceConflicts is passed through on the Create path Add tests to confirm that Install.ForceConflicts propagates to KubeClient.Create during performInstall when no existing resources need adoption (the fresh-install code path). - Add ResolveCreateOptions helper to kube package for test inspection - Record per-call ClientCreateOptions in FailingKubeClient - Add createNewResourceList helper returning NotFound from the fake REST client so resources flow through the Create path - TestInstallRelease_ForceConflictsPassedToCreate: SSA + ForceConflicts - TestInstallRelease_ForceConflictsFalseByDefault: SSA without force Made-with: Cursor --- pkg/action/install_test.go | 85 ++++++++++++++++++++++++++++ pkg/kube/client.go | 22 +++++++ pkg/kube/fake/failing_kube_client.go | 3 + 3 files changed, 110 insertions(+) diff --git a/pkg/action/install_test.go b/pkg/action/install_test.go index 05ca9a75e..aee8698b5 100644 --- a/pkg/action/install_test.go +++ b/pkg/action/install_test.go @@ -159,6 +159,41 @@ func createDummyCRDList(owned bool) kube.ResourceList { return resourceList } +func createNewResourceList() kube.ResourceList { + obj := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "dummyName", + 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, + } + + resInfo.Client = &fake.RESTClient{ + GroupVersion: schema.GroupVersion{Group: "apps", Version: "v1"}, + NegotiatedSerializer: scheme.Codecs.WithoutConversion(), + Client: fake.CreateHTTPClient(func(_ *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusNotFound, + Header: http.Header{"Content-Type": []string{kuberuntime.ContentTypeJSON}}, + Body: io.NopCloser(bytes.NewReader([]byte(`{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","reason":"NotFound","code":404}`))), + }, nil + }), + } + var resourceList kube.ResourceList + resourceList.Append(&resInfo) + return resourceList +} + func installActionWithConfig(config *Configuration) *Install { instAction := NewInstall(config) instAction.Namespace = "spaced" @@ -1297,3 +1332,53 @@ func TestInstallRelease_WaitOptionsPassedDownstream(t *testing.T) { // Verify that WaitOptions were passed to GetWaiter is.NotEmpty(failer.RecordedWaitOptions, "WaitOptions should be passed to GetWaiter") } + +func TestInstallRelease_ForceConflictsPassedToCreate(t *testing.T) { + is := assert.New(t) + + // Use resources whose REST client returns NotFound so they go through + // the Create path (toBeAdopted is empty) in performInstall. + config := actionConfigFixtureWithDummyResources(t, createNewResourceList()) + instAction := installActionWithConfig(config) + instAction.ServerSideApply = true + instAction.ForceConflicts = true + + failer := instAction.cfg.KubeClient.(*kubefake.FailingKubeClient) + + vals := map[string]any{} + _, err := instAction.Run(buildChart(), vals) + is.NoError(err) + + is.NotEmpty(failer.RecordedCreateCalls, "Create calls should be recorded") + var foundForceConflicts bool + for _, call := range failer.RecordedCreateCalls { + resolved, err := kube.ResolveCreateOptions(call...) + is.NoError(err) + if resolved.ForceConflicts { + foundForceConflicts = true + } + } + is.True(foundForceConflicts, "ForceConflicts should be passed through on the Create path") +} + +func TestInstallRelease_ForceConflictsFalseByDefault(t *testing.T) { + is := assert.New(t) + + config := actionConfigFixtureWithDummyResources(t, createNewResourceList()) + instAction := installActionWithConfig(config) + instAction.ServerSideApply = true + instAction.ForceConflicts = false + + failer := instAction.cfg.KubeClient.(*kubefake.FailingKubeClient) + + vals := map[string]any{} + _, err := instAction.Run(buildChart(), vals) + is.NoError(err) + + is.NotEmpty(failer.RecordedCreateCalls, "Create calls should be recorded") + for _, call := range failer.RecordedCreateCalls { + resolved, err := kube.ResolveCreateOptions(call...) + is.NoError(err) + is.False(resolved.ForceConflicts, "ForceConflicts should be false when not set") + } +} diff --git a/pkg/kube/client.go b/pkg/kube/client.go index c955e8875..9c5976ede 100644 --- a/pkg/kube/client.go +++ b/pkg/kube/client.go @@ -309,6 +309,28 @@ func ClientCreateOptionFieldValidationDirective(fieldValidationDirective FieldVa } } +// ResolvedCreateOptions holds the resolved values from ClientCreateOption functions. +// This is exported for testing purposes only. +type ResolvedCreateOptions struct { + ServerSideApply bool + ForceConflicts bool +} + +// ResolveCreateOptions applies the given ClientCreateOptions and returns the resolved values. +// This is exported for testing purposes only. +func ResolveCreateOptions(opts ...ClientCreateOption) (ResolvedCreateOptions, error) { + o := clientCreateOptions{} + for _, opt := range opts { + if err := opt(&o); err != nil { + return ResolvedCreateOptions{}, err + } + } + return ResolvedCreateOptions{ + ServerSideApply: o.serverSideApply, + ForceConflicts: o.forceConflicts, + }, nil +} + func (c *Client) makeCreateApplyFunc(serverSideApply, forceConflicts, dryRun bool, fieldValidationDirective FieldValidationDirective) CreateApplyFunc { if serverSideApply { c.Logger().Debug( diff --git a/pkg/kube/fake/failing_kube_client.go b/pkg/kube/fake/failing_kube_client.go index 0f7787f79..bff4086e6 100644 --- a/pkg/kube/fake/failing_kube_client.go +++ b/pkg/kube/fake/failing_kube_client.go @@ -49,6 +49,8 @@ type FailingKubeClient struct { WaitDuration time.Duration // RecordedWaitOptions stores the WaitOptions passed to GetWaiter for testing RecordedWaitOptions []kube.WaitOption + // RecordedCreateCalls stores the ClientCreateOptions for each call to Create for testing + RecordedCreateCalls [][]kube.ClientCreateOption } var _ kube.Interface = &FailingKubeClient{} @@ -65,6 +67,7 @@ type FailingKubeWaiter struct { // Create returns the configured error if set or prints func (f *FailingKubeClient) Create(resources kube.ResourceList, options ...kube.ClientCreateOption) (*kube.Result, error) { + f.RecordedCreateCalls = append(f.RecordedCreateCalls, options) if f.CreateError != nil { return nil, f.CreateError }