diff --git a/common/common.go b/common/common.go index 3872b0d7ef206..0b02807c29ba8 100644 --- a/common/common.go +++ b/common/common.go @@ -81,6 +81,10 @@ const ( // LabelValueSecretTypeCluster indicates a secret type of cluster LabelValueSecretTypeCluster = "cluster" + // AnnotationCompareOptions is a comma-separated list of options for comparison + AnnotationCompareOptions = "argocd.argoproj.io/compare-options" + // AnnotationSyncOptions is a comma-separated list of options for syncing + AnnotationSyncOptions = "argocd.argoproj.io/sync-options" // AnnotationSyncWave indicates which wave of the sync the resource or hook should be in AnnotationSyncWave = "argocd.argoproj.io/sync-wave" // AnnotationKeyHook contains the hook type of a resource diff --git a/controller/state.go b/controller/state.go index 65ab568c5a364..e3d8ce9ac3575 100644 --- a/controller/state.go +++ b/controller/state.go @@ -297,10 +297,11 @@ func (m *appStateManager) CompareAppState(app *v1alpha1.Application, revision st syncCode := v1alpha1.SyncStatusCodeSynced managedResources := make([]managedResource, len(targetObjs)) resourceSummaries := make([]v1alpha1.ResourceStatus, len(targetObjs)) - for i := 0; i < len(targetObjs); i++ { - obj := managedLiveObj[i] + for i, targetObj := range targetObjs { + liveObj := managedLiveObj[i] + obj := liveObj if obj == nil { - obj = targetObjs[i] + obj = targetObj } if obj == nil { continue @@ -319,13 +320,17 @@ func (m *appStateManager) CompareAppState(app *v1alpha1.Application, revision st diffResult := diffResults.Diffs[i] if resState.Hook || resource.Ignore(obj) { // For resource hooks, don't store sync status, and do not affect overall sync status - } else if diffResult.Modified || targetObjs[i] == nil || managedLiveObj[i] == nil { + } else if diffResult.Modified || targetObj == nil || liveObj == nil { // Set resource state to OutOfSync since one of the following is true: // * target and live resource are different // * target resource not defined and live resource is extra // * target resource present but live resource is missing resState.Status = v1alpha1.SyncStatusCodeOutOfSync - syncCode = v1alpha1.SyncStatusCodeOutOfSync + // we ignore the status if the obj needs pruning AND we have the annotation + needsPruning := targetObj == nil && liveObj != nil + if !(needsPruning && resource.HasAnnotationOption(obj, common.AnnotationCompareOptions, "IgnoreExtraneous")) { + syncCode = v1alpha1.SyncStatusCodeOutOfSync + } } else { resState.Status = v1alpha1.SyncStatusCodeSynced } @@ -335,8 +340,8 @@ func (m *appStateManager) CompareAppState(app *v1alpha1.Application, revision st Group: resState.Group, Kind: resState.Kind, Version: resState.Version, - Live: managedLiveObj[i], - Target: targetObjs[i], + Live: liveObj, + Target: targetObj, Diff: diffResult, Hook: resState.Hook, } diff --git a/controller/state_test.go b/controller/state_test.go index 2c7af500d3239..6ba9178f05aaa 100644 --- a/controller/state_test.go +++ b/controller/state_test.go @@ -117,6 +117,33 @@ func TestCompareAppStateHook(t *testing.T) { assert.Equal(t, 0, len(compRes.conditions)) } +// checks that ignore resources are detected, but excluded from status +func TestCompareAppStateCompareOptionIgnoreExtraneous(t *testing.T) { + pod := test.NewPod() + pod.SetAnnotations(map[string]string{common.AnnotationCompareOptions: "IgnoreExtraneous"}) + app := newFakeApp() + data := fakeData{ + apps: []runtime.Object{app}, + manifestResponse: &repository.ManifestResponse{ + Manifests: []string{}, + Namespace: test.FakeDestNamespace, + Server: test.FakeClusterURL, + Revision: "abc123", + }, + managedLiveObjs: make(map[kube.ResourceKey]*unstructured.Unstructured), + } + ctrl := newFakeController(&data) + + compRes, err := ctrl.appStateManager.CompareAppState(app, "", app.Spec.Source, false) + + assert.NoError(t, err) + assert.NotNil(t, compRes) + assert.Equal(t, argoappv1.SyncStatusCodeSynced, compRes.syncStatus.Status) + assert.Len(t, compRes.resources, 0) + assert.Len(t, compRes.managedResources, 0) + assert.Len(t, compRes.conditions, 0) +} + // TestCompareAppStateExtraHook tests when there is an extra _hook_ object in live but not defined in git func TestCompareAppStateExtraHook(t *testing.T) { pod := test.NewPod() diff --git a/controller/sync.go b/controller/sync.go index f6e7ca065b655..896a05fc29a24 100644 --- a/controller/sync.go +++ b/controller/sync.go @@ -16,6 +16,7 @@ import ( "k8s.io/client-go/dynamic" "k8s.io/client-go/rest" + "github.com/argoproj/argo-cd/common" "github.com/argoproj/argo-cd/controller/metrics" "github.com/argoproj/argo-cd/pkg/apis/application/v1alpha1" listersv1alpha1 "github.com/argoproj/argo-cd/pkg/client/listers/application/v1alpha1" @@ -23,6 +24,7 @@ import ( "github.com/argoproj/argo-cd/util/health" "github.com/argoproj/argo-cd/util/hook" "github.com/argoproj/argo-cd/util/kube" + "github.com/argoproj/argo-cd/util/resource" ) type syncContext struct { @@ -473,7 +475,11 @@ func (sc *syncContext) applyObject(targetObj *unstructured.Unstructured, dryRun // pruneObject deletes the object if both prune is true and dryRun is false. Otherwise appropriate message func (sc *syncContext) pruneObject(liveObj *unstructured.Unstructured, prune, dryRun bool) (v1alpha1.ResultCode, string) { - if prune { + if !prune { + return v1alpha1.ResultCodePruneSkipped, "ignored (requires pruning)" + } else if resource.HasAnnotationOption(liveObj, common.AnnotationSyncOptions, "Prune=false") { + return v1alpha1.ResultCodePruneSkipped, "ignored (no prune)" + } else { if dryRun { return v1alpha1.ResultCodePruned, "pruned (dry run)" } else { @@ -487,8 +493,6 @@ func (sc *syncContext) pruneObject(liveObj *unstructured.Unstructured, prune, dr } return v1alpha1.ResultCodePruned, "pruned" } - } else { - return v1alpha1.ResultCodePruneSkipped, "ignored (requires pruning)" } } diff --git a/controller/sync_test.go b/controller/sync_test.go index 202130777055f..d6af8ce3f0970 100644 --- a/controller/sync_test.go +++ b/controller/sync_test.go @@ -305,6 +305,26 @@ func TestDontSyncOrPruneHooks(t *testing.T) { assert.Equal(t, v1alpha1.OperationSucceeded, syncCtx.opState.Phase) } +// make sure that we do not prune resources with Prune=false +func TestDontPrunePruneFalse(t *testing.T) { + syncCtx := newTestSyncCtx() + pod := test.NewPod() + pod.SetAnnotations(map[string]string{common.AnnotationSyncOptions: "Prune=false"}) + pod.SetNamespace(test.FakeArgoCDNamespace) + syncCtx.compareResult = &comparisonResult{managedResources: []managedResource{{Live: pod}}} + + syncCtx.sync() + + assert.Equal(t, v1alpha1.OperationRunning, syncCtx.opState.Phase) + assert.Len(t, syncCtx.syncRes.Resources, 1) + assert.Equal(t, v1alpha1.ResultCodePruneSkipped, syncCtx.syncRes.Resources[0].Status) + assert.Equal(t, "ignored (no prune)", syncCtx.syncRes.Resources[0].Message) + + syncCtx.sync() + + assert.Equal(t, v1alpha1.OperationSucceeded, syncCtx.opState.Phase) +} + func TestSelectiveSyncOnly(t *testing.T) { syncCtx := newTestSyncCtx() pod1 := test.NewPod() diff --git a/docs/assets/compare-option-ignore-needs-pruning.png b/docs/assets/compare-option-ignore-needs-pruning.png new file mode 100644 index 0000000000000..78cfe688f9a07 Binary files /dev/null and b/docs/assets/compare-option-ignore-needs-pruning.png differ diff --git a/docs/assets/sync-option-no-prune-sync-status.png b/docs/assets/sync-option-no-prune-sync-status.png new file mode 100644 index 0000000000000..ae117e31d3759 Binary files /dev/null and b/docs/assets/sync-option-no-prune-sync-status.png differ diff --git a/docs/assets/sync-option-no-prune.png b/docs/assets/sync-option-no-prune.png new file mode 100644 index 0000000000000..1da6a0e1b8c46 Binary files /dev/null and b/docs/assets/sync-option-no-prune.png differ diff --git a/docs/user-guide/compare-options.md b/docs/user-guide/compare-options.md new file mode 100644 index 0000000000000..88d9350f7f97a --- /dev/null +++ b/docs/user-guide/compare-options.md @@ -0,0 +1,34 @@ +# Compare Options + +## Ignoring Resources That Are Extraneous + +You may wish to exclude resources from the app's overall sync status under certain circumstances. E.g. if they are generated by a tool. This can be done by adding this annotation: + +```yaml +metadata: + annotations: + argocd.argoproj.io/compare-options: IgnoreExtraneous +``` + +![compare option needs pruning](../assets/compare-option-ignore-needs-pruning.png) + +!!! note + This only affects the sync status. If the resource's health is degraded, then the app will also be degraded. + +Kustomize has a feature that allows you to generate config maps ([read more ⧉](https://github.com/kubernetes-sigs/kustomize/blob/master/examples/configGeneration.md)). You can set `generatorOptions` to add this annotation so that your app remains in sync: + +```yaml +configMapGenerator: + - name: my-map + literals: + - foo=bar +generatorOptions: + annotations: + argocd.argoproj.io/compare-options: IgnoreExtraneous +kind: Kustomization +``` + +!!! note + `generatorOptions` adds annotations to both config maps and secrets ([read more ⧉](https://github.com/kubernetes-sigs/kustomize/blob/master/examples/generatorOptions.md)). + +You may wish to combine this with the [`Prune=false` sync option](sync-options.md). diff --git a/docs/user-guide/kustomize.md b/docs/user-guide/kustomize.md index be6f485ec0877..fc9303070f242 100644 --- a/docs/user-guide/kustomize.md +++ b/docs/user-guide/kustomize.md @@ -10,4 +10,7 @@ You have three configuration options for Kustomize: * `imageTags` is a list of Kustomize 1.0 image tag overrides * `images` is a list of Kustomize 2.0 image overrides -To use Kustomize with an overlay, point your path to the overlay. \ No newline at end of file +To use Kustomize with an overlay, point your path to the overlay. + +!!! tip + If you're generating resources, you should read up how to ignore those generated resources using the [`IgnoreExtraneous` compare option](compare-options.md). diff --git a/docs/user-guide/sync-options.md b/docs/user-guide/sync-options.md new file mode 100644 index 0000000000000..4e8e5f6c7b41c --- /dev/null +++ b/docs/user-guide/sync-options.md @@ -0,0 +1,24 @@ +# Sync Options + +## No Prune Resources + +You may wish to prevent an object from being pruned: + +```yaml +metadata: + annotations: + argocd.argoproj.io/sync-options: Prune=false +``` + +In the UI, the pod will simply appear as out-of-sync: + +![sync option no prune](../assets/sync-option-no-prune.png) + + +The sync-status panel shows that pruning was skipped, and why: + +![sync option no prune](../assets/sync-option-no-prune-sync-status.png) + +!!! note + The app will be out of sync if Argo CD expects a resource to be pruned. You may wish to use this along with [compare options](compare-options.md). + diff --git a/mkdocs.yml b/mkdocs.yml index 6f502141eff32..b8954e7bb8d37 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -48,6 +48,8 @@ nav: - user-guide/private-repositories.md - user-guide/auto_sync.md - user-guide/diffing.md + - user-guide/compare-options.md + - user-guide/sync-options.md - user-guide/parameters.md - user-guide/tracking_strategies.md - user-guide/resource_hooks.md diff --git a/test/e2e/app_management_test.go b/test/e2e/app_management_test.go index 23b67dcdd579c..c5a7c57b4a9dd 100644 --- a/test/e2e/app_management_test.go +++ b/test/e2e/app_management_test.go @@ -306,15 +306,19 @@ func TestResourceDiffing(t *testing.T) { } func TestDeprecatedExtensions(t *testing.T) { - testEdgeCasesApplicationResources(t, "deprecated-extensions") + testEdgeCasesApplicationResources(t, "deprecated-extensions", OperationRunning, HealthStatusProgressing) } func TestCRDs(t *testing.T) { - testEdgeCasesApplicationResources(t, "crd-creation") + testEdgeCasesApplicationResources(t, "crd-creation", OperationSucceeded, HealthStatusHealthy) } func TestDuplicatedResources(t *testing.T) { - testEdgeCasesApplicationResources(t, "duplicated-resources") + testEdgeCasesApplicationResources(t, "duplicated-resources", OperationSucceeded, HealthStatusHealthy) +} + +func TestConfigMap(t *testing.T) { + testEdgeCasesApplicationResources(t, "config-map", OperationSucceeded, HealthStatusHealthy) } func TestFailedConversion(t *testing.T) { @@ -323,17 +327,19 @@ func TestFailedConversion(t *testing.T) { errors.FailOnErr(fixture.Run("", "kubectl", "delete", "apiservice", "v1beta1.metrics.k8s.io")) }() - testEdgeCasesApplicationResources(t, "failed-conversion") + testEdgeCasesApplicationResources(t, "failed-conversion", OperationSucceeded, HealthStatusHealthy) } -func testEdgeCasesApplicationResources(t *testing.T, appPath string) { +func testEdgeCasesApplicationResources(t *testing.T, appPath string, phase OperationPhase, statusCode HealthStatusCode) { Given(t). Path(appPath). When(). Create(). Sync(). Then(). + Expect(OperationPhaseIs(phase)). Expect(SyncStatusIs(SyncStatusCodeSynced)). + Expect(HealthIs(statusCode)). And(func(app *Application) { diffOutput, err := fixture.RunCli("app", "diff", app.Name, "--local", path.Join("testdata", appPath)) assert.Empty(t, diffOutput) @@ -504,3 +510,51 @@ func TestPermissions(t *testing.T) { assert.True(t, destinationErrorExist) assert.True(t, sourceErrorExist) } + +// make sure that if we deleted a resource from the app, it is not pruned if annotated with Prune=false +func TestSyncOptionPruneFalse(t *testing.T) { + Given(t). + Path("two-nice-pods"). + When(). + PatchFile("pod-1.yaml", `[{"op": "add", "path": "/metadata/annotations", "value": {"argocd.argoproj.io/sync-options": "Prune=false"}}]`). + Create(). + Sync(). + Then(). + Expect(OperationPhaseIs(OperationSucceeded)). + Expect(SyncStatusIs(SyncStatusCodeSynced)). + When(). + DeleteFile("pod-1.yaml"). + Refresh(RefreshTypeHard). + Sync(). + Then(). + Expect(OperationPhaseIs(OperationSucceeded)). + Expect(SyncStatusIs(SyncStatusCodeOutOfSync)). + Expect(ResourceSyncStatusIs("Pod", "pod-1", SyncStatusCodeOutOfSync)) +} + +// make sure that, if we have a resource that needs pruning, but we're ignoring it, the app is in-sync +func TestCompareOptionIgnoreExtraneous(t *testing.T) { + Given(t). + Path("two-nice-pods"). + When(). + PatchFile("pod-1.yaml", `[{"op": "add", "path": "/metadata/annotations", "value": {"argocd.argoproj.io/compare-options": "IgnoreExtraneous"}}]`). + Create(). + Sync(). + Then(). + Expect(OperationPhaseIs(OperationSucceeded)). + Expect(SyncStatusIs(SyncStatusCodeSynced)). + When(). + DeleteFile("pod-1.yaml"). + Refresh(RefreshTypeHard). + Then(). + Expect(SyncStatusIs(SyncStatusCodeSynced)). + And(func(app *Application) { + assert.Len(t, app.Status.Resources, 2) + assert.Equal(t, SyncStatusCodeOutOfSync, app.Status.Resources[1].Status) + }). + When(). + Sync(). + Then(). + Expect(OperationPhaseIs(OperationSucceeded)). + Expect(SyncStatusIs(SyncStatusCodeSynced)) +} diff --git a/test/e2e/fixture/fixture.go b/test/e2e/fixture/fixture.go index 57a69cca9cf4b..abb7bc0c78859 100644 --- a/test/e2e/fixture/fixture.go +++ b/test/e2e/fixture/fixture.go @@ -170,7 +170,7 @@ func EnsureCleanState(t *testing.T) { // new random ID id = dnsFriendly(t.Name()) - repoUrl = fmt.Sprintf("file:///%s", repoDirectory()) + repoUrl = fmt.Sprintf("file://%s", repoDirectory()) // create tmp dir FailOnErr(Run("", "mkdir", "-p", tmpDir)) diff --git a/test/e2e/kustomize_test.go b/test/e2e/kustomize_test.go index 16a80dae5e1c3..75ccf131457fb 100644 --- a/test/e2e/kustomize_test.go +++ b/test/e2e/kustomize_test.go @@ -52,3 +52,56 @@ func TestKustomize2AppSource(t *testing.T) { And(patchLabelMatchesFor("Service")). And(patchLabelMatchesFor("Deployment")) } + +// when we have a config map generator, AND the ignore annotation, it is ignored in the app's sync status +func TestSyncStatusOptionIgnore(t *testing.T) { + var mapName string + Given(t). + Path("kustomize-cm-gen"). + When(). + Create(). + Sync(). + Then(). + Expect(OperationPhaseIs(OperationSucceeded)). + Expect(SyncStatusIs(SyncStatusCodeSynced)). + Expect(HealthIs(HealthStatusHealthy)). + And(func(app *Application) { + resourceStatus := app.Status.Resources[0] + assert.Contains(t, resourceStatus.Name, "my-map-") + assert.Equal(t, SyncStatusCodeSynced, resourceStatus.Status) + + mapName = resourceStatus.Name + }). + When(). + // we now force generation of a second CM + PatchFile("kustomization.yaml", `[{"op": "replace", "path": "/configMapGenerator/0/literals/0", "value": "foo=baz"}]`). + Refresh(RefreshTypeHard). + Then(). + // this is standard logging from the command - tough one - true statement + When(). + Sync(). + Then(). + Expect(Error("1 resources require pruning")). + Expect(OperationPhaseIs(OperationSucceeded)). + // this is a key check - we expect the app to be healthy because, even though we have a resources that needs + // pruning, because it is annotated with IgnoreExtraneous it should not contribute to the sync status + Expect(SyncStatusIs(SyncStatusCodeSynced)). + Expect(HealthIs(HealthStatusHealthy)). + And(func(app *Application) { + assert.Equal(t, 2, len(app.Status.Resources)) + // new map in-sync + { + resourceStatus := app.Status.Resources[0] + assert.Contains(t, resourceStatus.Name, "my-map-") + // make sure we've a new map with changed name + assert.NotEqual(t, mapName, resourceStatus.Name) + assert.Equal(t, SyncStatusCodeSynced, resourceStatus.Status) + } + // old map is out of sync + { + resourceStatus := app.Status.Resources[1] + assert.Equal(t, mapName, resourceStatus.Name) + assert.Equal(t, SyncStatusCodeOutOfSync, resourceStatus.Status) + } + }) +} diff --git a/test/e2e/testdata/config-map/config-map.yaml b/test/e2e/testdata/config-map/config-map.yaml new file mode 100644 index 0000000000000..b781028c394f5 --- /dev/null +++ b/test/e2e/testdata/config-map/config-map.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: my-map +data: + foo: bar \ No newline at end of file diff --git a/test/e2e/testdata/kustomize-cm-gen/kustomization.yaml b/test/e2e/testdata/kustomize-cm-gen/kustomization.yaml new file mode 100644 index 0000000000000..5b47525454277 --- /dev/null +++ b/test/e2e/testdata/kustomize-cm-gen/kustomization.yaml @@ -0,0 +1,9 @@ +configMapGenerator: + - name: my-map + literals: + - foo=bar +generatorOptions: + annotations: + argocd.argoproj.io/compare-options: IgnoreExtraneous + argocd.argoproj.io/sync-options: Prune=false +kind: Kustomization diff --git a/util/resource/annotations.go b/util/resource/annotations.go new file mode 100644 index 0000000000000..0b6018f4dfa02 --- /dev/null +++ b/util/resource/annotations.go @@ -0,0 +1,16 @@ +package resource + +import ( + "strings" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func HasAnnotationOption(obj *unstructured.Unstructured, key, val string) bool { + for _, item := range strings.Split(obj.GetAnnotations()[key], ",") { + if strings.TrimSpace(item) == val { + return true + } + } + return false +} diff --git a/util/resource/annotations_test.go b/util/resource/annotations_test.go new file mode 100644 index 0000000000000..a437f3c9b4139 --- /dev/null +++ b/util/resource/annotations_test.go @@ -0,0 +1,41 @@ +package resource + +import ( + "testing" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/argoproj/argo-cd/test" +) + +func TestHasAnnotationOption(t *testing.T) { + type args struct { + obj *unstructured.Unstructured + key string + val string + } + tests := []struct { + name string + args args + want bool + }{ + {"Nil", args{test.NewPod(), "foo", "bar"}, false}, + {"Empty", args{example(""), "foo", "bar"}, false}, + {"Single", args{example("bar"), "foo", "bar"}, true}, + {"Double", args{example("bar,baz"), "foo", "baz"}, true}, + {"Spaces", args{example("bar "), "foo", "bar"}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := HasAnnotationOption(tt.args.obj, tt.args.key, tt.args.val); got != tt.want { + t.Errorf("HasAnnotationOption() = %v, want %v", got, tt.want) + } + }) + } +} + +func example(val string) *unstructured.Unstructured { + obj := test.NewPod() + obj.SetAnnotations(map[string]string{"foo": val}) + return obj +}