diff --git a/controller/appcontroller_test.go b/controller/appcontroller_test.go index 87be3743181b6..81299fe65f562 100644 --- a/controller/appcontroller_test.go +++ b/controller/appcontroller_test.go @@ -989,7 +989,7 @@ func TestNormalizeApplication(t *testing.T) { normalized := false fakeAppCs.AddReactor("patch", "*", func(action kubetesting.Action) (handled bool, ret runtime.Object, err error) { if patchAction, ok := action.(kubetesting.PatchAction); ok { - if string(patchAction.GetPatch()) == `{"spec":{"project":"default"}}` { + if string(patchAction.GetPatch()) == `{"spec":{"project":"default"},"status":{"sync":{"comparedTo":{"destination":{},"source":{"repoURL":""}}}}}` { normalized = true } } @@ -1011,7 +1011,7 @@ func TestNormalizeApplication(t *testing.T) { normalized := false fakeAppCs.AddReactor("patch", "*", func(action kubetesting.Action) (handled bool, ret runtime.Object, err error) { if patchAction, ok := action.(kubetesting.PatchAction); ok { - if string(patchAction.GetPatch()) == `{"spec":{"project":"default"}}` { + if string(patchAction.GetPatch()) == `{"spec":{"project":"default"},"status":{"sync":{"comparedTo":{"destination":{},"source":{"repoURL":""}}}}}` { normalized = true } } diff --git a/pkg/apis/application/v1alpha1/generated.proto b/pkg/apis/application/v1alpha1/generated.proto index 88ba0d7efe9a9..bde433c406540 100644 --- a/pkg/apis/application/v1alpha1/generated.proto +++ b/pkg/apis/application/v1alpha1/generated.proto @@ -2227,6 +2227,7 @@ message SyncStatus { optional string status = 1; // ComparedTo contains information about what has been compared + // +patchStrategy=replace optional ComparedTo comparedTo = 2; // Revision contains information about the revision the comparison has been performed to diff --git a/pkg/apis/application/v1alpha1/openapi_generated.go b/pkg/apis/application/v1alpha1/openapi_generated.go index ecbcdabe75364..c5a41de677314 100644 --- a/pkg/apis/application/v1alpha1/openapi_generated.go +++ b/pkg/apis/application/v1alpha1/openapi_generated.go @@ -7705,6 +7705,11 @@ func schema_pkg_apis_application_v1alpha1_SyncStatus(ref common.ReferenceCallbac }, }, "comparedTo": { + VendorExtensible: spec.VendorExtensible{ + Extensions: spec.Extensions{ + "x-kubernetes-patch-strategy": "replace", + }, + }, SchemaProps: spec.SchemaProps{ Description: "ComparedTo contains information about what has been compared", Default: map[string]interface{}{}, diff --git a/pkg/apis/application/v1alpha1/types.go b/pkg/apis/application/v1alpha1/types.go index e8d9dcb3f36bf..1ea2277e30599 100644 --- a/pkg/apis/application/v1alpha1/types.go +++ b/pkg/apis/application/v1alpha1/types.go @@ -1509,7 +1509,8 @@ type SyncStatus struct { // Status is the sync state of the comparison Status SyncStatusCode `json:"status" protobuf:"bytes,1,opt,name=status,casttype=SyncStatusCode"` // ComparedTo contains information about what has been compared - ComparedTo ComparedTo `json:"comparedTo,omitempty" protobuf:"bytes,2,opt,name=comparedTo"` + // +patchStrategy=replace + ComparedTo ComparedTo `json:"comparedTo,omitempty" protobuf:"bytes,2,opt,name=comparedTo" patchStrategy:"replace"` // Revision contains information about the revision the comparison has been performed to Revision string `json:"revision,omitempty" protobuf:"bytes,3,opt,name=revision"` // Revisions contains information about the revisions of multiple sources the comparison has been performed to diff --git a/pkg/apis/application/v1alpha1/types_test.go b/pkg/apis/application/v1alpha1/types_test.go index e1cbfa74a388a..817003b06a0ea 100644 --- a/pkg/apis/application/v1alpha1/types_test.go +++ b/pkg/apis/application/v1alpha1/types_test.go @@ -11,7 +11,10 @@ import ( "testing" "time" + "github.com/argoproj/gitops-engine/pkg/diff" "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/utils/pointer" argocdcommon "github.com/argoproj/argo-cd/v2/common" @@ -3667,3 +3670,35 @@ func TestApplicationSpec_GetSourcePtrByIndex(t *testing.T) { }) } } + +func TestHelmValuesObjectHasReplaceStrategy(t *testing.T) { + app := Application{ + Status: ApplicationStatus{Sync: SyncStatus{ComparedTo: ComparedTo{ + Source: ApplicationSource{ + Helm: &ApplicationSourceHelm{ + ValuesObject: &runtime.RawExtension{ + Object: &unstructured.Unstructured{Object: map[string]interface{}{"key": []string{"value"}}}, + }, + }, + }, + }}}, + } + + appModified := Application{ + Status: ApplicationStatus{Sync: SyncStatus{ComparedTo: ComparedTo{ + Source: ApplicationSource{ + Helm: &ApplicationSourceHelm{ + ValuesObject: &runtime.RawExtension{ + Object: &unstructured.Unstructured{Object: map[string]interface{}{"key": []string{"value-modified1"}}}, + }, + }, + }, + }}}, + } + + patch, _, err := diff.CreateTwoWayMergePatch( + app, + appModified, Application{}) + require.NoError(t, err) + assert.Equal(t, `{"status":{"sync":{"comparedTo":{"destination":{},"source":{"helm":{"valuesObject":{"key":["value-modified1"]}},"repoURL":""}}}}}`, string(patch)) +} diff --git a/test/e2e/app_management_test.go b/test/e2e/app_management_test.go index 378af7b033330..257e8a27ecbd0 100644 --- a/test/e2e/app_management_test.go +++ b/test/e2e/app_management_test.go @@ -21,6 +21,7 @@ import ( rbacv1 "k8s.io/api/rbac/v1" 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" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/intstr" @@ -2846,3 +2847,44 @@ func TestAnnotationTrackingExtraResources(t *testing.T) { Expect(HealthIs(health.HealthStatusHealthy)) } + +// Test designed to cover #15126. +// The issue occurs in the controller, when a valuesObject field that contains non-strings (eg, a nested map) gets +// merged/patched. +// Note: Failure is observed by the test timing out, because the controller cannot 'merge' the patch. +func TestPatchValuesObject(t *testing.T) { + + Given(t). + Timeout(30). + Path("helm"). + When(). + // app should be auto-synced once created + CreateFromFile(func(app *Application) { + app.Spec.Source.Helm = &ApplicationSourceHelm{ + ValuesObject: &runtime.RawExtension{ + // Setup by using nested YAML objects, which is what causes the patch error: + // "unable to find api field in struct RawExtension for the json field "some"" + Raw: []byte(`{"some": {"foo": "bar"}}`), + }, + } + }). + Then(). + When(). + PatchApp(`[{ + "op": "add", + "path": "/spec/source/helm/valuesObject", + "value": {"some":{"foo":"bar","new":"field"}} + }]`). + Refresh(RefreshTypeNormal). + Sync(). + Then(). + Expect(Success("")). + Expect(OperationPhaseIs(OperationSucceeded)). + Expect(SyncStatusIs(SyncStatusCodeSynced)). + Expect(NoConditions()). + And(func(app *Application) { + // Check that the patch was a success. + assert.Equal(t, `{"some":{"foo":"bar","new":"field"}}`, string(app.Spec.Source.Helm.ValuesObject.Raw)) + }) + +} diff --git a/test/e2e/applicationset_test.go b/test/e2e/applicationset_test.go index 0d4d8ea3498f5..f28fde1f977df 100644 --- a/test/e2e/applicationset_test.go +++ b/test/e2e/applicationset_test.go @@ -2676,3 +2676,88 @@ func TestGitGeneratorPrivateRepoGoTemplate(t *testing.T) { When(). Delete().Then().Expect(ApplicationsDoNotExist(expectedAppsNewNamespace)) } + +func TestUpdateHelmValuesObject(t *testing.T) { + + expectedApp := argov1alpha1.Application{ + TypeMeta: metav1.TypeMeta{ + Kind: application.ApplicationKind, + APIVersion: "argoproj.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-cluster-guestbook", + Namespace: fixture.TestNamespace(), + Finalizers: []string{"resources-finalizer.argocd.argoproj.io"}, + }, + Spec: argov1alpha1.ApplicationSpec{ + Project: "default", + Source: &argov1alpha1.ApplicationSource{ + RepoURL: "https://github.com/argoproj/argocd-example-apps.git", + TargetRevision: "HEAD", + Path: "helm-guestbook", + Helm: &argov1alpha1.ApplicationSourceHelm{ + ValuesObject: &runtime.RawExtension{ + // This will always be converted as yaml + Raw: []byte(`{"some":{"foo":"bar"}}`), + }, + }, + }, + Destination: argov1alpha1.ApplicationDestination{ + Server: "https://kubernetes.default.svc", + Namespace: "guestbook", + }, + }, + } + + Given(t). + // Create a ListGenerator-based ApplicationSet + When().Create(v1alpha1.ApplicationSet{ObjectMeta: metav1.ObjectMeta{ + Name: "test-values-object-patch", + }, + Spec: v1alpha1.ApplicationSetSpec{ + GoTemplate: true, + Template: v1alpha1.ApplicationSetTemplate{ + ApplicationSetTemplateMeta: v1alpha1.ApplicationSetTemplateMeta{Name: "{{.cluster}}-guestbook"}, + Spec: argov1alpha1.ApplicationSpec{ + Project: "default", + Source: &argov1alpha1.ApplicationSource{ + RepoURL: "https://github.com/argoproj/argocd-example-apps.git", + TargetRevision: "HEAD", + Path: "helm-guestbook", + Helm: &argov1alpha1.ApplicationSourceHelm{ + ValuesObject: &runtime.RawExtension{ + Raw: []byte(`{"some":{"string":"{{.test}}"}}`), + }, + }, + }, + Destination: argov1alpha1.ApplicationDestination{ + Server: "{{.url}}", + Namespace: "guestbook", + }, + }, + }, + Generators: []v1alpha1.ApplicationSetGenerator{ + { + List: &v1alpha1.ListGenerator{ + Elements: []apiextensionsv1.JSON{{ + Raw: []byte(`{"cluster": "my-cluster","url": "https://kubernetes.default.svc", "test": "Hello world"}`), + }}, + }, + }, + }, + }, + }).Then(). + Expect(ApplicationSetHasConditions("test-values-object-patch", ExpectedConditions)). + When(). + // Update the app spec with some knew ValuesObject to force a merge + Update(func(as *argov1alpha1.ApplicationSet) { + as.Spec.Template.Spec.Source.Helm.ValuesObject = &runtime.RawExtension{ + Raw: []byte(`{"some":{"foo":"bar"}}`), + } + }). + Then(). + Expect(ApplicationsExist([]argov1alpha1.Application{expectedApp})). + When(). + // Delete the ApplicationSet, and verify it deletes the Applications + Delete().Then().Expect(ApplicationsDoNotExist([]argov1alpha1.Application{expectedApp})) +}