diff --git a/applicationset/controllers/applicationset_controller.go b/applicationset/controllers/applicationset_controller.go index 7466a2394bd9e..385d476752aef 100644 --- a/applicationset/controllers/applicationset_controller.go +++ b/applicationset/controllers/applicationset_controller.go @@ -16,7 +16,6 @@ package controllers import ( "context" - "encoding/json" "fmt" "reflect" "time" @@ -25,7 +24,6 @@ import ( corev1 "k8s.io/api/core/v1" apierr "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" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/intstr" @@ -46,7 +44,6 @@ import ( "github.com/argoproj/argo-cd/v2/applicationset/generators" "github.com/argoproj/argo-cd/v2/applicationset/utils" "github.com/argoproj/argo-cd/v2/common" - argodiff "github.com/argoproj/argo-cd/v2/util/argo/diff" "github.com/argoproj/argo-cd/v2/util/db" "github.com/argoproj/argo-cd/v2/util/glob" @@ -628,7 +625,7 @@ func (r *ApplicationSetReconciler) createOrUpdateInCluster(ctx context.Context, }, } - action, err := utils.CreateOrUpdate(ctx, r.Client, found, func() error { + action, err := utils.CreateOrUpdate(ctx, appLog, r.Client, applicationSet.Spec.IgnoreApplicationDifferences, found, func() error { // Copy only the Application/ObjectMeta fields that are significant, from the generatedApp found.Spec = generatedApp.Spec @@ -681,13 +678,6 @@ func (r *ApplicationSetReconciler) createOrUpdateInCluster(ctx context.Context, found.ObjectMeta.Finalizers = generatedApp.Finalizers found.ObjectMeta.Labels = generatedApp.Labels - if found != nil && len(found.Spec.IgnoreDifferences) > 0 { - err := applyIgnoreDifferences(applicationSet.Spec.IgnoreApplicationDifferences, found, generatedApp) - if err != nil { - return fmt.Errorf("failed to apply ignore differences: %w", err) - } - } - return controllerutil.SetControllerReference(&applicationSet, found, r.Scheme) }) @@ -713,54 +703,6 @@ func (r *ApplicationSetReconciler) createOrUpdateInCluster(ctx context.Context, return firstError } -// applyIgnoreDifferences applies the ignore differences rules to the found application. It modifies the found application in place. -func applyIgnoreDifferences(applicationSetIgnoreDifferences argov1alpha1.ApplicationSetIgnoreDifferences, found *argov1alpha1.Application, generatedApp argov1alpha1.Application) error { - diffConfig, err := argodiff.NewDiffConfigBuilder(). - WithDiffSettings(applicationSetIgnoreDifferences.ToApplicationIgnoreDifferences(), nil, false). - WithNoCache(). - Build() - if err != nil { - return fmt.Errorf("failed to build diff config: %w", err) - } - unstructuredFound, err := appToUnstructured(found) - if err != nil { - return fmt.Errorf("failed to convert found application to unstructured: %w", err) - } - unstructuredGenerated, err := appToUnstructured(&generatedApp) - if err != nil { - return fmt.Errorf("failed to convert found application to unstructured: %w", err) - } - result, err := argodiff.Normalize([]*unstructured.Unstructured{unstructuredFound}, []*unstructured.Unstructured{unstructuredGenerated}, diffConfig) - if err != nil { - return fmt.Errorf("failed to normalize application spec: %w", err) - } - if len(result.Targets) != 1 { - return fmt.Errorf("expected 1 normalized application, got %d", len(result.Targets)) - } - jsonNormalized, err := json.Marshal(result.Targets[0].Object) - if err != nil { - return fmt.Errorf("failed to marshal normalized app to json: %w", err) - } - err = json.Unmarshal(jsonNormalized, &found) - if err != nil { - return fmt.Errorf("failed to unmarshal normalized app json to structured app: %w", err) - } - // Prohibit jq queries from mutating silly things. - found.TypeMeta = generatedApp.TypeMeta - found.Name = generatedApp.Name - found.Namespace = generatedApp.Namespace - found.Operation = generatedApp.Operation - return nil -} - -func appToUnstructured(app *argov1alpha1.Application) (*unstructured.Unstructured, error) { - u, err := runtime.DefaultUnstructuredConverter.ToUnstructured(app) - if err != nil { - return nil, fmt.Errorf("failed to convert app object to unstructured: %w", err) - } - return &unstructured.Unstructured{Object: u}, nil -} - // createInCluster will filter from the desiredApplications only the application that needs to be created // Then it will call createOrUpdateInCluster to do the actual create func (r *ApplicationSetReconciler) createInCluster(ctx context.Context, logCtx *log.Entry, applicationSet argov1alpha1.ApplicationSet, desiredApplications []argov1alpha1.Application) error { @@ -914,7 +856,11 @@ func (r *ApplicationSetReconciler) removeFinalizerOnInvalidDestination(ctx conte if len(newFinalizers) != len(app.Finalizers) { updated := app.DeepCopy() updated.Finalizers = newFinalizers - if err := r.Client.Patch(ctx, updated, client.MergeFrom(app)); err != nil { + patch := client.MergeFrom(app) + if log.IsLevelEnabled(log.DebugLevel) { + utils.LogPatch(appLog, patch, updated) + } + if err := r.Client.Patch(ctx, updated, patch); err != nil { return fmt.Errorf("error updating finalizers: %w", err) } r.updateCache(ctx, updated, appLog) diff --git a/applicationset/controllers/applicationset_controller_test.go b/applicationset/controllers/applicationset_controller_test.go index 1d282ac32423b..add37780e7077 100644 --- a/applicationset/controllers/applicationset_controller_test.go +++ b/applicationset/controllers/applicationset_controller_test.go @@ -12,8 +12,6 @@ import ( log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" - "gopkg.in/yaml.v2" corev1 "k8s.io/api/core/v1" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -981,6 +979,296 @@ func TestCreateOrUpdateInCluster(t *testing.T) { }, }, }, + }, { + // For this use case: https://github.com/argoproj/argo-cd/issues/9101#issuecomment-1191138278 + name: "Ensure that ignored targetRevision difference doesn't cause an update, even if another field changes", + appSet: v1alpha1.ApplicationSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + Namespace: "namespace", + }, + Spec: v1alpha1.ApplicationSetSpec{ + IgnoreApplicationDifferences: v1alpha1.ApplicationSetIgnoreDifferences{ + {JQPathExpressions: []string{".spec.source.targetRevision"}}, + }, + Template: v1alpha1.ApplicationSetTemplate{ + Spec: v1alpha1.ApplicationSpec{ + Project: "project", + Source: &v1alpha1.ApplicationSource{ + RepoURL: "https://git.example.com/test-org/test-repo.git", + TargetRevision: "foo", + }, + }, + }, + }, + }, + existingApps: []v1alpha1.Application{ + { + TypeMeta: metav1.TypeMeta{ + Kind: "Application", + APIVersion: "argoproj.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "app1", + Namespace: "namespace", + ResourceVersion: "2", + }, + Spec: v1alpha1.ApplicationSpec{ + Project: "project", + Source: &v1alpha1.ApplicationSource{ + RepoURL: "https://git.example.com/test-org/test-repo.git", + TargetRevision: "bar", + }, + }, + }, + }, + desiredApps: []v1alpha1.Application{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "app1", + }, + Spec: v1alpha1.ApplicationSpec{ + Project: "project", + Source: &v1alpha1.ApplicationSource{ + RepoURL: "https://git.example.com/test-org/test-repo.git", + // The targetRevision is ignored, so this should not be updated. + TargetRevision: "foo", + // This should be updated. + Helm: &v1alpha1.ApplicationSourceHelm{ + Parameters: []v1alpha1.HelmParameter{ + {Name: "hi", Value: "there"}, + }, + }, + }, + }, + }, + }, + expected: []v1alpha1.Application{ + { + TypeMeta: metav1.TypeMeta{ + Kind: "Application", + APIVersion: "argoproj.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "app1", + Namespace: "namespace", + ResourceVersion: "3", + }, + Spec: v1alpha1.ApplicationSpec{ + Project: "project", + Source: &v1alpha1.ApplicationSource{ + RepoURL: "https://git.example.com/test-org/test-repo.git", + // This is the existing value from the cluster, which should not be updated because the field is ignored. + TargetRevision: "bar", + // This was missing on the cluster, so it should be added. + Helm: &v1alpha1.ApplicationSourceHelm{ + Parameters: []v1alpha1.HelmParameter{ + {Name: "hi", Value: "there"}, + }, + }, + }, + }, + }, + }, + }, { + // For this use case: https://github.com/argoproj/argo-cd/pull/14743#issuecomment-1761954799 + name: "ignore parameters added to a multi-source app in the cluster", + appSet: v1alpha1.ApplicationSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + Namespace: "namespace", + }, + Spec: v1alpha1.ApplicationSetSpec{ + IgnoreApplicationDifferences: v1alpha1.ApplicationSetIgnoreDifferences{ + {JQPathExpressions: []string{`.spec.sources[] | select(.repoURL | contains("test-repo")).helm.parameters`}}, + }, + Template: v1alpha1.ApplicationSetTemplate{ + Spec: v1alpha1.ApplicationSpec{ + Project: "project", + Sources: []v1alpha1.ApplicationSource{ + { + RepoURL: "https://git.example.com/test-org/test-repo.git", + Helm: &v1alpha1.ApplicationSourceHelm{ + Values: "foo: bar", + }, + }, + }, + }, + }, + }, + }, + existingApps: []v1alpha1.Application{ + { + TypeMeta: metav1.TypeMeta{ + Kind: "Application", + APIVersion: "argoproj.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "app1", + Namespace: "namespace", + ResourceVersion: "2", + }, + Spec: v1alpha1.ApplicationSpec{ + Project: "project", + Sources: []v1alpha1.ApplicationSource{ + { + RepoURL: "https://git.example.com/test-org/test-repo.git", + Helm: &v1alpha1.ApplicationSourceHelm{ + Values: "foo: bar", + Parameters: []v1alpha1.HelmParameter{ + {Name: "hi", Value: "there"}, + }, + }, + }, + }, + }, + }, + }, + desiredApps: []v1alpha1.Application{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "app1", + }, + Spec: v1alpha1.ApplicationSpec{ + Project: "project", + Sources: []v1alpha1.ApplicationSource{ + { + RepoURL: "https://git.example.com/test-org/test-repo.git", + Helm: &v1alpha1.ApplicationSourceHelm{ + Values: "foo: bar", + }, + }, + }, + }, + }, + }, + expected: []v1alpha1.Application{ + { + TypeMeta: metav1.TypeMeta{ + Kind: "Application", + APIVersion: "argoproj.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "app1", + Namespace: "namespace", + // This should not be updated, because reconciliation shouldn't modify the App. + ResourceVersion: "2", + }, + Spec: v1alpha1.ApplicationSpec{ + Project: "project", + Sources: []v1alpha1.ApplicationSource{ + { + RepoURL: "https://git.example.com/test-org/test-repo.git", + Helm: &v1alpha1.ApplicationSourceHelm{ + Values: "foo: bar", + Parameters: []v1alpha1.HelmParameter{ + // This existed only in the cluster, but it shouldn't be removed, because the field is ignored. + {Name: "hi", Value: "there"}, + }, + }, + }, + }, + }, + }, + }, + }, { + name: "Demonstrate limitation of MergePatch", // Maybe we can fix this in Argo CD 3.0: https://github.com/argoproj/argo-cd/issues/15975 + appSet: v1alpha1.ApplicationSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + Namespace: "namespace", + }, + Spec: v1alpha1.ApplicationSetSpec{ + IgnoreApplicationDifferences: v1alpha1.ApplicationSetIgnoreDifferences{ + {JQPathExpressions: []string{`.spec.sources[] | select(.repoURL | contains("test-repo")).helm.parameters`}}, + }, + Template: v1alpha1.ApplicationSetTemplate{ + Spec: v1alpha1.ApplicationSpec{ + Project: "project", + Sources: []v1alpha1.ApplicationSource{ + { + RepoURL: "https://git.example.com/test-org/test-repo.git", + Helm: &v1alpha1.ApplicationSourceHelm{ + Values: "new: values", + }, + }, + }, + }, + }, + }, + }, + existingApps: []v1alpha1.Application{ + { + TypeMeta: metav1.TypeMeta{ + Kind: "Application", + APIVersion: "argoproj.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "app1", + Namespace: "namespace", + ResourceVersion: "2", + }, + Spec: v1alpha1.ApplicationSpec{ + Project: "project", + Sources: []v1alpha1.ApplicationSource{ + { + RepoURL: "https://git.example.com/test-org/test-repo.git", + Helm: &v1alpha1.ApplicationSourceHelm{ + Values: "foo: bar", + Parameters: []v1alpha1.HelmParameter{ + {Name: "hi", Value: "there"}, + }, + }, + }, + }, + }, + }, + }, + desiredApps: []v1alpha1.Application{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "app1", + }, + Spec: v1alpha1.ApplicationSpec{ + Project: "project", + Sources: []v1alpha1.ApplicationSource{ + { + RepoURL: "https://git.example.com/test-org/test-repo.git", + Helm: &v1alpha1.ApplicationSourceHelm{ + Values: "new: values", + }, + }, + }, + }, + }, + }, + expected: []v1alpha1.Application{ + { + TypeMeta: metav1.TypeMeta{ + Kind: "Application", + APIVersion: "argoproj.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "app1", + Namespace: "namespace", + ResourceVersion: "3", + }, + Spec: v1alpha1.ApplicationSpec{ + Project: "project", + Sources: []v1alpha1.ApplicationSource{ + { + RepoURL: "https://git.example.com/test-org/test-repo.git", + Helm: &v1alpha1.ApplicationSourceHelm{ + Values: "new: values", + // The Parameters field got blown away, because the values field changed. MergePatch + // doesn't merge list items, it replaces the whole list if an item changes. + // If we eventually add a `name` field to Sources, we can use StrategicMergePatch. + }, + }, + }, + }, + }, + }, }, } { @@ -1004,7 +1292,7 @@ func TestCreateOrUpdateInCluster(t *testing.T) { } err = r.createOrUpdateInCluster(context.TODO(), log.NewEntry(log.StandardLogger()), c.appSet, c.desiredApps) - assert.Nil(t, err) + assert.NoError(t, err) for _, obj := range c.expected { got := &v1alpha1.Application{} @@ -1014,7 +1302,6 @@ func TestCreateOrUpdateInCluster(t *testing.T) { }, got) err = controllerutil.SetControllerReference(&c.appSet, &obj, r.Scheme) - assert.Nil(t, err) assert.Equal(t, obj, *got) } }) @@ -5727,173 +6014,3 @@ func TestOwnsHandler(t *testing.T) { }) } } - -func Test_applyIgnoreDifferences(t *testing.T) { - appMeta := metav1.TypeMeta{ - APIVersion: v1alpha1.ApplicationSchemaGroupVersionKind.GroupVersion().String(), - Kind: v1alpha1.ApplicationSchemaGroupVersionKind.Kind, - } - testCases := []struct { - name string - ignoreDifferences v1alpha1.ApplicationSetIgnoreDifferences - foundApp string - generatedApp string - expectedApp string - }{ - { - name: "empty ignoreDifferences", - foundApp: ` -spec: {}`, - generatedApp: ` -spec: {}`, - expectedApp: ` -spec: {}`, - }, - { - // For this use case: https://github.com/argoproj/argo-cd/issues/9101#issuecomment-1191138278 - name: "ignore target revision with jq", - ignoreDifferences: v1alpha1.ApplicationSetIgnoreDifferences{ - {JQPathExpressions: []string{".spec.source.targetRevision"}}, - }, - foundApp: ` -spec: - source: - targetRevision: foo`, - generatedApp: ` -spec: - source: - targetRevision: bar`, - expectedApp: ` -spec: - source: - targetRevision: foo`, - }, - { - // For this use case: https://github.com/argoproj/argo-cd/issues/9101#issuecomment-1103593714 - name: "ignore helm parameter with jq", - ignoreDifferences: v1alpha1.ApplicationSetIgnoreDifferences{ - {JQPathExpressions: []string{`.spec.source.helm.parameters | select(.name == "image.tag")`}}, - }, - foundApp: ` -spec: - source: - helm: - parameters: - - name: image.tag - value: test - - name: another - value: value`, - generatedApp: ` -spec: - source: - helm: - parameters: - - name: image.tag - value: v1.0.0 - - name: another - value: value`, - expectedApp: ` -spec: - source: - helm: - parameters: - - name: image.tag - value: test - - name: another - value: value`, - }, - { - // For this use case: https://github.com/argoproj/argo-cd/issues/9101#issuecomment-1191138278 - name: "ignore auto-sync with jq", - ignoreDifferences: v1alpha1.ApplicationSetIgnoreDifferences{ - {JQPathExpressions: []string{".spec.syncPolicy.automated"}}, - }, - foundApp: ` -spec: - syncPolicy: - retry: - limit: 5`, - generatedApp: ` -spec: - syncPolicy: - automated: - selfHeal: true - retry: - limit: 5`, - expectedApp: ` -spec: - syncPolicy: - retry: - limit: 5`, - }, - { - // For this use case: https://github.com/argoproj/argo-cd/issues/9101#issuecomment-1420656537 - name: "ignore a one-off annotation with jq", - ignoreDifferences: v1alpha1.ApplicationSetIgnoreDifferences{ - {JQPathExpressions: []string{`.metadata.annotations | select(.["foo.bar"] == "baz")`}}, - }, - foundApp: ` -metadata: - annotations: - foo.bar: baz - some.other: annotation`, - generatedApp: ` -metadata: - annotations: - some.other: annotation`, - expectedApp: ` -metadata: - annotations: - foo.bar: baz - some.other: annotation`, - }, - { - // For this use case: https://github.com/argoproj/argo-cd/issues/9101#issuecomment-1515672638 - name: "ignore the source.plugin field with a json pointer", - ignoreDifferences: v1alpha1.ApplicationSetIgnoreDifferences{ - {JSONPointers: []string{"/spec/source/plugin"}}, - }, - foundApp: ` -spec: - source: - plugin: - parameters: - - name: url - string: https://example.com`, - generatedApp: ` -spec: - source: - plugin: - parameters: - - name: url - string: https://example.com/wrong`, - expectedApp: ` -spec: - source: - plugin: - parameters: - - name: url - string: https://example.com`, - }, - } - - for _, tc := range testCases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - foundApp := v1alpha1.Application{TypeMeta: appMeta} - err := yaml.Unmarshal([]byte(tc.foundApp), &foundApp) - require.NoError(t, err, tc.foundApp) - generatedApp := v1alpha1.Application{TypeMeta: appMeta} - err = yaml.Unmarshal([]byte(tc.generatedApp), &generatedApp) - require.NoError(t, err, tc.generatedApp) - err = applyIgnoreDifferences(tc.ignoreDifferences, &foundApp, generatedApp) - require.NoError(t, err) - jsonFound, err := json.Marshal(tc.foundApp) - require.NoError(t, err) - jsonExpected, err := json.Marshal(tc.expectedApp) - require.NoError(t, err) - assert.Equal(t, string(jsonExpected), string(jsonFound)) - }) - } -} diff --git a/applicationset/utils/createOrUpdate.go b/applicationset/utils/createOrUpdate.go index 274070c2ef5be..1f2a8a9c4a54c 100644 --- a/applicationset/utils/createOrUpdate.go +++ b/applicationset/utils/createOrUpdate.go @@ -2,19 +2,24 @@ package utils import ( "context" + "encoding/json" "fmt" + log "github.com/sirupsen/logrus" "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/conversion" "k8s.io/apimachinery/pkg/fields" "k8s.io/apimachinery/pkg/labels" - "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" argov1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" + "github.com/argoproj/argo-cd/v2/util/argo" + argodiff "github.com/argoproj/argo-cd/v2/util/argo/diff" ) // CreateOrUpdate overrides "sigs.k8s.io/controller-runtime" function @@ -30,7 +35,7 @@ import ( // The MutateFn is called regardless of creating or updating an object. // // It returns the executed operation and an error. -func CreateOrUpdate(ctx context.Context, c client.Client, obj client.Object, f controllerutil.MutateFn) (controllerutil.OperationResult, error) { +func CreateOrUpdate(ctx context.Context, logCtx *log.Entry, c client.Client, ignoreAppDifferences argov1alpha1.ApplicationSetIgnoreDifferences, obj *argov1alpha1.Application, f controllerutil.MutateFn) (controllerutil.OperationResult, error) { key := client.ObjectKeyFromObject(obj) if err := c.Get(ctx, key, obj); err != nil { @@ -46,15 +51,24 @@ func CreateOrUpdate(ctx context.Context, c client.Client, obj client.Object, f c return controllerutil.OperationResultCreated, nil } - existingObj := obj.DeepCopyObject() - existing, ok := existingObj.(client.Object) - if !ok { - panic(fmt.Errorf("existing object is not a client.Object")) - } + normalizedLive := obj.DeepCopy() + + // Mutate the live object to match the desired state. if err := mutate(f, key, obj); err != nil { return controllerutil.OperationResultNone, err } + // Apply ignoreApplicationDifferences rules to remove ignored fields from both the live and the desired state. This + // prevents those differences from appearing in the diff and therefore in the patch. + err := applyIgnoreDifferences(ignoreAppDifferences, normalizedLive, obj) + if err != nil { + return controllerutil.OperationResultNone, fmt.Errorf("failed to apply ignore differences: %w", err) + } + + // Normalize to avoid diffing on unimportant differences. + normalizedLive.Spec = *argo.NormalizeApplicationSpec(&normalizedLive.Spec) + obj.Spec = *argo.NormalizeApplicationSpec(&obj.Spec) + equality := conversion.EqualitiesOrDie( func(a, b resource.Quantity) bool { // Ignore formatting, only care that numeric value stayed the same. @@ -79,23 +93,35 @@ func CreateOrUpdate(ctx context.Context, c client.Client, obj client.Object, f c return a.Namespace == b.Namespace && a.Name == b.Name && a.Server == b.Server }, ) - // make sure updated object has the same apiVersion & kind as original object - if objKind, ok := obj.(schema.ObjectKind); ok { - if existingKind, ok := existing.(schema.ObjectKind); ok { - existingKind.SetGroupVersionKind(objKind.GroupVersionKind()) - } - } - if equality.DeepEqual(existing, obj) { + if equality.DeepEqual(normalizedLive, obj) { return controllerutil.OperationResultNone, nil } - if err := c.Patch(ctx, obj, client.MergeFrom(existing)); err != nil { + patch := client.MergeFrom(normalizedLive) + if log.IsLevelEnabled(log.DebugLevel) { + LogPatch(logCtx, patch, obj) + } + if err := c.Patch(ctx, obj, patch); err != nil { return controllerutil.OperationResultNone, err } return controllerutil.OperationResultUpdated, nil } +func LogPatch(logCtx *log.Entry, patch client.Patch, obj *argov1alpha1.Application) { + patchBytes, err := patch.Data(obj) + if err != nil { + logCtx.Errorf("failed to generate patch: %v", err) + } + // Get the patch as a plain object so it is easier to work with in json logs. + var patchObj map[string]interface{} + err = json.Unmarshal(patchBytes, &patchObj) + if err != nil { + logCtx.Errorf("failed to unmarshal patch: %v", err) + } + logCtx.WithField("patch", patchObj).Debug("patching application") +} + // mutate wraps a MutateFn and applies validation to its result func mutate(f controllerutil.MutateFn, key client.ObjectKey, obj client.Object) error { if err := f(); err != nil { @@ -106,3 +132,71 @@ func mutate(f controllerutil.MutateFn, key client.ObjectKey, obj client.Object) } return nil } + +// applyIgnoreDifferences applies the ignore differences rules to the found application. It modifies the applications in place. +func applyIgnoreDifferences(applicationSetIgnoreDifferences argov1alpha1.ApplicationSetIgnoreDifferences, found *argov1alpha1.Application, generatedApp *argov1alpha1.Application) error { + if len(applicationSetIgnoreDifferences) == 0 { + return nil + } + + generatedAppCopy := generatedApp.DeepCopy() + diffConfig, err := argodiff.NewDiffConfigBuilder(). + WithDiffSettings(applicationSetIgnoreDifferences.ToApplicationIgnoreDifferences(), nil, false). + WithNoCache(). + Build() + if err != nil { + return fmt.Errorf("failed to build diff config: %w", err) + } + unstructuredFound, err := appToUnstructured(found) + if err != nil { + return fmt.Errorf("failed to convert found application to unstructured: %w", err) + } + unstructuredGenerated, err := appToUnstructured(generatedApp) + if err != nil { + return fmt.Errorf("failed to convert found application to unstructured: %w", err) + } + result, err := argodiff.Normalize([]*unstructured.Unstructured{unstructuredFound}, []*unstructured.Unstructured{unstructuredGenerated}, diffConfig) + if err != nil { + return fmt.Errorf("failed to normalize application spec: %w", err) + } + if len(result.Lives) != 1 { + return fmt.Errorf("expected 1 normalized application, got %d", len(result.Lives)) + } + foundJsonNormalized, err := json.Marshal(result.Lives[0].Object) + if err != nil { + return fmt.Errorf("failed to marshal normalized app to json: %w", err) + } + foundNormalized := &argov1alpha1.Application{} + err = json.Unmarshal(foundJsonNormalized, &foundNormalized) + if err != nil { + return fmt.Errorf("failed to unmarshal normalized app to json: %w", err) + } + if len(result.Targets) != 1 { + return fmt.Errorf("expected 1 normalized application, got %d", len(result.Targets)) + } + foundNormalized.DeepCopyInto(found) + generatedJsonNormalized, err := json.Marshal(result.Targets[0].Object) + if err != nil { + return fmt.Errorf("failed to marshal normalized app to json: %w", err) + } + generatedAppNormalized := &argov1alpha1.Application{} + err = json.Unmarshal(generatedJsonNormalized, &generatedAppNormalized) + if err != nil { + return fmt.Errorf("failed to unmarshal normalized app json to structured app: %w", err) + } + generatedAppNormalized.DeepCopyInto(generatedApp) + // Prohibit jq queries from mutating silly things. + generatedApp.TypeMeta = generatedAppCopy.TypeMeta + generatedApp.Name = generatedAppCopy.Name + generatedApp.Namespace = generatedAppCopy.Namespace + generatedApp.Operation = generatedAppCopy.Operation + return nil +} + +func appToUnstructured(app client.Object) (*unstructured.Unstructured, error) { + u, err := runtime.DefaultUnstructuredConverter.ToUnstructured(app) + if err != nil { + return nil, fmt.Errorf("failed to convert app object to unstructured: %w", err) + } + return &unstructured.Unstructured{Object: u}, nil +} diff --git a/applicationset/utils/createOrUpdate_test.go b/applicationset/utils/createOrUpdate_test.go new file mode 100644 index 0000000000000..a294e89281974 --- /dev/null +++ b/applicationset/utils/createOrUpdate_test.go @@ -0,0 +1,234 @@ +package utils + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" +) + +func Test_applyIgnoreDifferences(t *testing.T) { + appMeta := metav1.TypeMeta{ + APIVersion: v1alpha1.ApplicationSchemaGroupVersionKind.GroupVersion().String(), + Kind: v1alpha1.ApplicationSchemaGroupVersionKind.Kind, + } + testCases := []struct { + name string + ignoreDifferences v1alpha1.ApplicationSetIgnoreDifferences + foundApp string + generatedApp string + expectedApp string + }{ + { + name: "empty ignoreDifferences", + foundApp: ` +spec: {}`, + generatedApp: ` +spec: {}`, + expectedApp: ` +spec: {}`, + }, + { + // For this use case: https://github.com/argoproj/argo-cd/issues/9101#issuecomment-1191138278 + name: "ignore target revision with jq", + ignoreDifferences: v1alpha1.ApplicationSetIgnoreDifferences{ + {JQPathExpressions: []string{".spec.source.targetRevision"}}, + }, + foundApp: ` +spec: + source: + targetRevision: foo`, + generatedApp: ` +spec: + source: + targetRevision: bar`, + expectedApp: ` +spec: + source: + targetRevision: foo`, + }, + { + // For this use case: https://github.com/argoproj/argo-cd/issues/9101#issuecomment-1103593714 + name: "ignore helm parameter with jq", + ignoreDifferences: v1alpha1.ApplicationSetIgnoreDifferences{ + {JQPathExpressions: []string{`.spec.source.helm.parameters | select(.name == "image.tag")`}}, + }, + foundApp: ` +spec: + source: + helm: + parameters: + - name: image.tag + value: test + - name: another + value: value`, + generatedApp: ` +spec: + source: + helm: + parameters: + - name: image.tag + value: v1.0.0 + - name: another + value: value`, + expectedApp: ` +spec: + source: + helm: + parameters: + - name: image.tag + value: test + - name: another + value: value`, + }, + { + // For this use case: https://github.com/argoproj/argo-cd/issues/9101#issuecomment-1191138278 + name: "ignore auto-sync in appset when it's not in the cluster with jq", + ignoreDifferences: v1alpha1.ApplicationSetIgnoreDifferences{ + {JQPathExpressions: []string{".spec.syncPolicy.automated"}}, + }, + foundApp: ` +spec: + syncPolicy: + retry: + limit: 5`, + generatedApp: ` +spec: + syncPolicy: + automated: + selfHeal: true + retry: + limit: 5`, + expectedApp: ` +spec: + syncPolicy: + retry: + limit: 5`, + }, + { + name: "ignore auto-sync in the cluster when it's not in the appset with jq", + ignoreDifferences: v1alpha1.ApplicationSetIgnoreDifferences{ + {JQPathExpressions: []string{".spec.syncPolicy.automated"}}, + }, + foundApp: ` +spec: + syncPolicy: + automated: + selfHeal: true + retry: + limit: 5`, + generatedApp: ` +spec: + syncPolicy: + retry: + limit: 5`, + expectedApp: ` +spec: + syncPolicy: + automated: + selfHeal: true + retry: + limit: 5`, + }, + { + // For this use case: https://github.com/argoproj/argo-cd/issues/9101#issuecomment-1420656537 + name: "ignore a one-off annotation with jq", + ignoreDifferences: v1alpha1.ApplicationSetIgnoreDifferences{ + {JQPathExpressions: []string{`.metadata.annotations | select(.["foo.bar"] == "baz")`}}, + }, + foundApp: ` +metadata: + annotations: + foo.bar: baz + some.other: annotation`, + generatedApp: ` +metadata: + annotations: + some.other: annotation`, + expectedApp: ` +metadata: + annotations: + foo.bar: baz + some.other: annotation`, + }, + { + // For this use case: https://github.com/argoproj/argo-cd/issues/9101#issuecomment-1515672638 + name: "ignore the source.plugin field with a json pointer", + ignoreDifferences: v1alpha1.ApplicationSetIgnoreDifferences{ + {JSONPointers: []string{"/spec/source/plugin"}}, + }, + foundApp: ` +spec: + source: + plugin: + parameters: + - name: url + string: https://example.com`, + generatedApp: ` +spec: + source: + plugin: + parameters: + - name: url + string: https://example.com/wrong`, + expectedApp: ` +spec: + source: + plugin: + parameters: + - name: url + string: https://example.com`, + }, + { + // For this use case: https://github.com/argoproj/argo-cd/pull/14743#issuecomment-1761954799 + name: "ignore parameters added to a multi-source app in the cluster", + ignoreDifferences: v1alpha1.ApplicationSetIgnoreDifferences{ + {JQPathExpressions: []string{`.spec.sources[] | select(.repoURL | contains("test-repo")).helm.parameters`}}, + }, + foundApp: ` +spec: + sources: + - repoURL: https://git.example.com/test-org/test-repo + helm: + parameters: + - name: test + value: hi`, + generatedApp: ` +spec: + sources: + - repoURL: https://git.example.com/test-org/test-repo`, + expectedApp: ` +spec: + sources: + - repoURL: https://git.example.com/test-org/test-repo + helm: + parameters: + - name: test + value: hi`, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + foundApp := v1alpha1.Application{TypeMeta: appMeta} + err := yaml.Unmarshal([]byte(tc.foundApp), &foundApp) + require.NoError(t, err, tc.foundApp) + generatedApp := v1alpha1.Application{TypeMeta: appMeta} + err = yaml.Unmarshal([]byte(tc.generatedApp), &generatedApp) + require.NoError(t, err, tc.generatedApp) + err = applyIgnoreDifferences(tc.ignoreDifferences, &foundApp, &generatedApp) + require.NoError(t, err) + yamlFound, err := yaml.Marshal(tc.foundApp) + require.NoError(t, err) + yamlExpected, err := yaml.Marshal(tc.expectedApp) + require.NoError(t, err) + assert.Equal(t, string(yamlExpected), string(yamlFound)) + }) + } +} diff --git a/docs/operator-manual/applicationset/Controlling-Resource-Modification.md b/docs/operator-manual/applicationset/Controlling-Resource-Modification.md index 73f8a5a3eeb50..44b6797b24d11 100644 --- a/docs/operator-manual/applicationset/Controlling-Resource-Modification.md +++ b/docs/operator-manual/applicationset/Controlling-Resource-Modification.md @@ -6,7 +6,7 @@ These settings allow you to exert control over when, and how, changes are made t Here are some of the controller settings that may be modified to alter the ApplicationSet controller's resource-handling behaviour. -### Dry run: prevent ApplicationSet from creating, modifying, or deleting all Applications +## Dry run: prevent ApplicationSet from creating, modifying, or deleting all Applications To prevent the ApplicationSet controller from creating, modifying, or deleting any `Application` resources, you may enable `dry-run` mode. This essentially switches the controller into a "read only" mode, where the controller Reconcile loop will run, but no resources will be modified. @@ -14,7 +14,7 @@ To enable dry-run, add `--dryrun true` to the ApplicationSet Deployment's contai See 'How to modify ApplicationSet container parameters' below for detailed steps on how to add this parameter to the controller. -### Managed Applications modification Policies +## Managed Applications modification Policies The ApplicationSet controller supports a parameter `--policy`, which is specified on launch (within the controller Deployment container), and which restricts what types of modifications will be made to managed Argo CD `Application` resources. @@ -41,7 +41,7 @@ If the controller parameter `--policy` is set, it takes precedence on the field This does not prevent deletion of Applications if the ApplicationSet is deleted -#### Controller parameter +### Controller parameter To allow the ApplicationSet controller to *create* `Application` resources, but prevent any further modification, such as deletion, or modification of Application fields, add this parameter in the ApplicationSet controller: ``` @@ -59,7 +59,7 @@ spec: applicationsSync: create-only ``` -### Policy - `create-update`: Prevent ApplicationSet controller from deleting Applications +## Policy - `create-update`: Prevent ApplicationSet controller from deleting Applications To allow the ApplicationSet controller to create or modify `Application` resources, but prevent Applications from being deleted, add the following parameter to the ApplicationSet controller `Deployment`: ``` @@ -79,7 +79,7 @@ spec: applicationsSync: create-update ``` -### Ignore certain changes to Applications +## Ignore certain changes to Applications The ApplicationSet spec includes an `ignoreApplicationDifferences` field, which allows you to specify which fields of the ApplicationSet should be ignored when comparing Applications. @@ -98,11 +98,94 @@ spec: - jsonPointers: - /spec/source/targetRevision - name: some-app - jqExpressions: + jqPathExpressions: - .spec.source.helm.values ``` -### Prevent an `Application`'s child resources from being deleted, when the parent Application is deleted +### Allow temporarily toggling auto-sync + +One of the most common use cases for ignoring differences is to allow temporarily toggling auto-sync for an Application. + +For example, if you have an ApplicationSet that is configured to automatically sync Applications, you may want to temporarily +disable auto-sync for a specific Application. You can do this by adding an ignore rule for the `spec.syncPolicy.automated` field. + +```yaml +apiVersion: argoproj.io/v1alpha1 +kind: ApplicationSet +spec: + ignoreApplicationDifferences: + - jsonPointers: + - /spec/syncPolicy +``` + +### Limitations of `ignoreApplicationDifferences` + +When an ApplicationSet is reconciled, the controller will compare the ApplicationSet spec with the spec of each Application +that it manages. If there are any differences, the controller will generate a patch to update the Application to match the +ApplicationSet spec. + +The generated patch is a MergePatch. According to the MergePatch documentation, "existing lists will be completely +replaced by new lists" when there is a change to the list. + +This limits the effectiveness of `ignoreApplicationDifferences` when the ignored field is in a list. For example, if you +have an application with multiple sources, and you want to ignore changes to the `targetRevision` of one of the sources, +changes in other fields or in other sources will cause the entire `sources` list to be replaced, and the `targetRevision` +field will be reset to the value defined in the ApplicationSet. + +For example, consider this ApplicationSet: + +```yaml +apiVersion: argoproj.io/v1alpha1 +kind: ApplicationSet +spec: + ignoreApplicationDifferences: + - jqPathExpressions: + - .spec.sources[] | select(.repoURL == "https://git.example.com/org/repo1").targetRevision + template: + spec: + sources: + - repoURL: https://git.example.com/org/repo1 + targetRevision: main + - repoURL: https://git.example.com/org/repo2 + targetRevision: main +``` + +You can freely change the `targetRevision` of the `repo1` source, and the ApplicationSet controller will not overwrite +your change. + +```yaml +apiVersion: argoproj.io/v1alpha1 +kind: Application +spec: + sources: + - repoURL: https://git.example.com/org/repo1 + targetRevision: fix/bug-123 + - repoURL: https://git.example.com/org/repo2 + targetRevision: main +``` + +However, if you change the `targetRevision` of the `repo2` source, the ApplicationSet controller will overwrite the entire +`sources` field. + +```yaml +apiVersion: argoproj.io/v1alpha1 +kind: Application +spec: + sources: + - repoURL: https://git.example.com/org/repo1 + targetRevision: main + - repoURL: https://git.example.com/org/repo2 + targetRevision: main +``` + +!!! note + [Future improvements](https://github.com/argoproj/argo-cd/issues/15975) to the ApplicationSet controller may + eliminate this problem. For example, the `ref` field might be made a merge key, allowing the ApplicationSet + controller to generate and use a StrategicMergePatch instead of a MergePatch. You could then target a specific + source by `ref`, ignore changes to a field in that source, and changes to other sources would not cause the ignored + field to be overwritten. + +## Prevent an `Application`'s child resources from being deleted, when the parent Application is deleted By default, when an `Application` resource is deleted by the ApplicationSet controller, all of the child resources of the Application will be deleted as well (such as, all of the Application's `Deployments`, `Services`, etc). @@ -119,7 +202,7 @@ spec: More information on the specific behaviour of `preserveResourcesOnDeletion`, and deletion in ApplicationSet controller and Argo CD in general, can be found on the [Application Deletion](Application-Deletion.md) page. -### Prevent an Application's child resources from being modified +## Prevent an Application's child resources from being modified Changes made to the ApplicationSet will propagate to the Applications managed by the ApplicationSet, and then Argo CD will propagate the Application changes to the underlying cluster resources (as per [Argo CD Integration](Argo-CD-Integration.md)). @@ -185,6 +268,11 @@ kubectl apply -n argocd -f install.yaml ## Preserving changes made to an Applications annotations and labels +!!! note + The same behavior can be achieved on a per-app basis using the [`ignoreApplicationDifferences`](#ignore-certain-changes-to-applications) + feature described above. However, preserved fields may be configured globally, a feature that is not yet available + for `ignoreApplicationDifferences`. + It is common practice in Kubernetes to store state in annotations, operators will often make use of this. To allow for this, it is possible to configure a list of annotations that the ApplicationSet should preserve when reconciling. For example, imagine that we have an Application created from an ApplicationSet, but a custom annotation and label has since been added (to the Application) that does not exist in the `ApplicationSet` resource: @@ -220,3 +308,18 @@ By default, the Argo CD notifications and the Argo CD refresh type annotations a !!!note One can also set global preserved fields for the controller by passing a comma separated list of annotations and labels to `ARGOCD_APPLICATIONSET_CONTROLLER_GLOBAL_PRESERVED_ANNOTATIONS` and `ARGOCD_APPLICATIONSET_CONTROLLER_GLOBAL_PRESERVED_LABELS` respectively. + +## Debugging unexpected changes to Applications + +When the ApplicationSet controller makes a change to an application, it logs the patch at the debug level. To see these +logs, set the log level to debug in the `argocd-cmd-params-cm` ConfigMap in the `argocd` namespace: + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: argocd-cmd-params-cm + namespace: argocd +data: + applicationsetcontroller.log.level: debug +``` diff --git a/pkg/apis/application/v1alpha1/types.go b/pkg/apis/application/v1alpha1/types.go index 8d02fa5bfb624..c2aa7f2d10bdb 100644 --- a/pkg/apis/application/v1alpha1/types.go +++ b/pkg/apis/application/v1alpha1/types.go @@ -1135,11 +1135,12 @@ type SyncPolicy struct { Retry *RetryStrategy `json:"retry,omitempty" protobuf:"bytes,3,opt,name=retry"` // ManagedNamespaceMetadata controls metadata in the given namespace (if CreateNamespace=true) ManagedNamespaceMetadata *ManagedNamespaceMetadata `json:"managedNamespaceMetadata,omitempty" protobuf:"bytes,4,opt,name=managedNamespaceMetadata"` + // If you add a field here, be sure to update IsZero. } // IsZero returns true if the sync policy is empty func (p *SyncPolicy) IsZero() bool { - return p == nil || (p.Automated == nil && len(p.SyncOptions) == 0 && p.Retry == nil) + return p == nil || (p.Automated == nil && len(p.SyncOptions) == 0 && p.Retry == nil && p.ManagedNamespaceMetadata == nil) } // RetryStrategy contains information about the strategy to apply when a sync failed diff --git a/util/argo/argo.go b/util/argo/argo.go index 9187726ab17dc..36e513cf0f534 100644 --- a/util/argo/argo.go +++ b/util/argo/argo.go @@ -855,7 +855,9 @@ func NormalizeApplicationSpec(spec *argoappv1.ApplicationSpec) *argoappv1.Applic if spec.Project == "" { spec.Project = argoappv1.DefaultAppProjectName } - + if spec.SyncPolicy.IsZero() { + spec.SyncPolicy = nil + } if spec.Sources != nil && len(spec.Sources) > 0 { for _, source := range spec.Sources { NormalizeSource(&source)