diff --git a/controller/appcontroller.go b/controller/appcontroller.go index cf4d7df68c986..4a5b6da739f51 100644 --- a/controller/appcontroller.go +++ b/controller/appcontroller.go @@ -1168,8 +1168,13 @@ func (ctrl *ApplicationController) removeProjectFinalizer(proj *appv1.AppProject // shouldBeDeleted returns whether a given resource obj should be deleted on cascade delete of application app func (ctrl *ApplicationController) shouldBeDeleted(app *appv1.Application, obj *unstructured.Unstructured) bool { + deleteOption := resourceutil.GetAnnotationOptionValue(obj, synccommon.AnnotationSyncOptions, synccommon.SyncOptionDelete) + if deleteOption == nil && app.Spec.SyncPolicy != nil { + deleteOption = app.Spec.SyncPolicy.SyncOptions.GetOptionValue(synccommon.SyncOptionDelete) + } + return !kube.IsCRD(obj) && !isSelfReferencedApp(app, kube.GetObjectRef(obj)) && - !resourceutil.HasAnnotationOption(obj, synccommon.AnnotationSyncOptions, synccommon.SyncOptionDisableDeletion) && + (deleteOption == nil || *deleteOption != synccommon.SyncValueFalse) && !resourceutil.HasAnnotationOption(obj, helm.ResourcePolicyAnnotation, helm.ResourcePolicyKeep) } diff --git a/controller/appcontroller_test.go b/controller/appcontroller_test.go index e3dba83be7348..52d2047e60fa0 100644 --- a/controller/appcontroller_test.go +++ b/controller/appcontroller_test.go @@ -2985,6 +2985,21 @@ func Test_syncDeleteOption(t *testing.T) { cmObj.SetAnnotations(map[string]string{"helm.sh/resource-policy": "keep"}) assert.False(t, ctrl.shouldBeDeleted(app, cmObj)) }) + + t.Run("delete set on the app level", func(t *testing.T) { + newApp := app.DeepCopy() + newApp.Spec.SyncPolicy.SyncOptions = []string{"Delete=false"} + cmObj := kube.MustToUnstructured(&cm) + cmObj.SetAnnotations(map[string]string{}) + assert.False(t, ctrl.shouldBeDeleted(newApp, cmObj)) + }) + t.Run("delete should be overridden on the resource", func(t *testing.T) { + newApp := app.DeepCopy() + newApp.Spec.SyncPolicy.SyncOptions = []string{"Delete=false"} + cmObj := kube.MustToUnstructured(&cm) + cmObj.SetAnnotations(map[string]string{"argocd.argoproj.io/sync-options": "Delete=foo"}) + assert.True(t, ctrl.shouldBeDeleted(newApp, cmObj)) + }) } func TestAddControllerNamespace(t *testing.T) { diff --git a/controller/state.go b/controller/state.go index e2a393e2e06fc..4852d10cc6d70 100644 --- a/controller/state.go +++ b/controller/state.go @@ -882,17 +882,14 @@ func (m *appStateManager) CompareAppState(app *v1alpha1.Application, project *v1 isSelfReferencedObj := m.isSelfReferencedObj(liveObj, targetObj, app.GetName(), v1alpha1.TrackingMethod(trackingMethod), installationID) resState := v1alpha1.ResourceStatus{ - Namespace: obj.GetNamespace(), - Name: obj.GetName(), - Kind: gvk.Kind, - Version: gvk.Version, - Group: gvk.Group, - Hook: isHook(obj), - RequiresPruning: targetObj == nil && liveObj != nil && isSelfReferencedObj, - RequiresDeletionConfirmation: targetObj != nil && resourceutil.HasAnnotationOption(targetObj, synccommon.AnnotationSyncOptions, synccommon.SyncOptionDeleteRequireConfirm) || - liveObj != nil && resourceutil.HasAnnotationOption(liveObj, synccommon.AnnotationSyncOptions, synccommon.SyncOptionDeleteRequireConfirm) || - targetObj != nil && resourceutil.HasAnnotationOption(targetObj, synccommon.AnnotationSyncOptions, synccommon.SyncOptionPruneRequireConfirm) || - liveObj != nil && resourceutil.HasAnnotationOption(liveObj, synccommon.AnnotationSyncOptions, synccommon.SyncOptionPruneRequireConfirm), + Namespace: obj.GetNamespace(), + Name: obj.GetName(), + Kind: gvk.Kind, + Version: gvk.Version, + Group: gvk.Group, + Hook: isHook(obj), + RequiresPruning: targetObj == nil && liveObj != nil && isSelfReferencedObj, + RequiresDeletionConfirmation: isObjRequiresDeletionConfirmation(targetObj, app) || isObjRequiresDeletionConfirmation(liveObj, app), } if targetObj != nil { resState.SyncWave = int64(syncwaves.Wave(targetObj)) @@ -1092,6 +1089,29 @@ func specEqualsCompareTo(spec v1alpha1.ApplicationSpec, sources []v1alpha1.Appli return reflect.DeepEqual(comparedTo, compareToSpec) } +func isObjRequiresDeletionConfirmation(obj *unstructured.Unstructured, app *v1alpha1.Application) bool { + if obj == nil { + return false + } + deleteOption := resourceutil.GetAnnotationOptionValue(obj, synccommon.AnnotationSyncOptions, synccommon.SyncOptionDelete) + if deleteOption == nil && app.Spec.SyncPolicy != nil { + deleteOption = app.Spec.SyncPolicy.SyncOptions.GetOptionValue(synccommon.SyncOptionDelete) + } + if deleteOption != nil && *deleteOption == synccommon.SyncValueConfirm { + return true + } + + pruneOption := resourceutil.GetAnnotationOptionValue(obj, synccommon.AnnotationSyncOptions, synccommon.SyncOptionPrune) + if pruneOption == nil && app.Spec.SyncPolicy != nil { + pruneOption = app.Spec.SyncPolicy.SyncOptions.GetOptionValue(synccommon.SyncOptionPrune) + } + if pruneOption != nil && *pruneOption == synccommon.SyncValueConfirm { + return true + } + + return false +} + func (m *appStateManager) persistRevisionHistory( app *v1alpha1.Application, revision string, diff --git a/controller/state_test.go b/controller/state_test.go index 92bd4bb3507d4..2aa0bd58f9ddd 100644 --- a/controller/state_test.go +++ b/controller/state_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "os" + "strings" "testing" "time" @@ -2038,3 +2039,72 @@ func TestCompareAppState_CallUpdateRevisionForPaths_ForMultiSource(t *testing.T) require.NoError(t, err) require.False(t, revisionsMayHaveChanges) } + +func Test_isObjRequiresDeletionConfirmation(t *testing.T) { + for _, tt := range []struct { + name string + resourceSyncOptions []string + appSyncOptions []string + expected bool + }{ + { + name: "default", + expected: false, + }, + { + name: "confirm delete resource", + resourceSyncOptions: []string{"Delete=confirm"}, + expected: true, + }, + { + name: "confirm delete app", + appSyncOptions: []string{"Delete=confirm"}, + expected: true, + }, + { + name: "confirm prune resource", + appSyncOptions: []string{"Prune=confirm"}, + expected: true, + }, + { + name: "confirm app & resource delete", + appSyncOptions: []string{"Delete=confirm"}, + resourceSyncOptions: []string{"Delete=confirm"}, + expected: true, + }, + { + name: "confirm app & resource override", + appSyncOptions: []string{"Delete=confirm"}, + resourceSyncOptions: []string{"Delete=foo"}, + expected: false, + }, + { + name: "confirm app & resource mixed delete and prune", + appSyncOptions: []string{"Prune=confirm"}, + resourceSyncOptions: []string{"Delete=confirm"}, + expected: true, + }, + { + name: "override prune resource", + appSyncOptions: []string{"Prune=confirm"}, + resourceSyncOptions: []string{"Prune=foo"}, + expected: false, + }, + { + name: "override delete resource and additional delete confirm", + appSyncOptions: []string{"Delete=confirm", "Prune=confirm"}, + resourceSyncOptions: []string{"Delete=foo"}, + expected: true, + }, + } { + t.Run(tt.name, func(t *testing.T) { + obj := NewPod() + obj.SetAnnotations(map[string]string{"argocd.argoproj.io/sync-options": strings.Join(tt.resourceSyncOptions, ",")}) + + app := newFakeApp() + app.Spec.SyncPolicy.SyncOptions = tt.appSyncOptions + + require.Equal(t, tt.expected, isObjRequiresDeletionConfirmation(obj, app)) + }) + } +} diff --git a/controller/sync.go b/controller/sync.go index c0715357098a6..0a1af8f6fa7d0 100644 --- a/controller/sync.go +++ b/controller/sync.go @@ -347,6 +347,7 @@ func (m *appStateManager) SyncAppState(app *v1alpha1.Application, project *v1alp clientSideApplyManager, ), sync.WithPruneConfirmed(app.IsDeletionConfirmed(state.StartedAt.Time)), + sync.WithDefaultPruneOption(syncOp.SyncOptions.GetOptionValue(common.SyncOptionPrune)), sync.WithSkipDryRunOnMissingResource(syncOp.SyncOptions.HasOption(common.SyncOptionSkipDryRunOnMissingResource)), } diff --git a/docs/user-guide/sync-options.md b/docs/user-guide/sync-options.md index d99c532d3b7b8..7b1efb5ca82c5 100644 --- a/docs/user-guide/sync-options.md +++ b/docs/user-guide/sync-options.md @@ -14,6 +14,20 @@ metadata: argocd.argoproj.io/sync-options: Prune=false ``` +It is also possible to set this option as a default option on the application level: + +```yaml +apiVersion: argoproj.io/v1alpha1 +kind: Application +spec: + syncPolicy: + syncOptions: + - Prune=false +``` + +Note that setting a Prune sync option on the resource will always override a +Prune sync policy defined in the Application. + The sync-status panel shows that pruning was skipped, and why: ![sync option no prune](../assets/sync-option-no-prune-sync-status.png) @@ -39,6 +53,21 @@ confirmed. The UI will look similar to this, with the "Confirm Pruning" button a ![Screenshot of the Argo CD Application UI. The "Last Sync" section shows that the operation is still Syncing. The row of gray action buttons includes an extra "Confirm Pruning" button.](../assets/confirm-prune.png) +It is also possible to set this option as a default option on the application level: + + +```yaml +apiVersion: argoproj.io/v1alpha1 +kind: Application +spec: + syncPolicy: + syncOptions: + - Prune=confirm +``` + +Note that setting a Prune sync option on the resource will always override a +Prune sync policy defined in the Application. + ## Disable Kubectl Validation For a certain class of objects, it is necessary to `kubectl apply` them using the `--validate=false` flag. Examples of this are Kubernetes types which uses `RawExtension`, such as [ServiceCatalog](https://github.com/kubernetes-incubator/service-catalog/blob/master/pkg/apis/servicecatalog/v1beta1/types.go#L497). You can do that using this annotation: @@ -92,6 +121,21 @@ metadata: argocd.argoproj.io/sync-options: Delete=false ``` +It is also possible to set this option as a default option on the application level: + + +```yaml +apiVersion: argoproj.io/v1alpha1 +kind: Application +spec: + syncPolicy: + syncOptions: + - Delete=false +``` + +Note that setting a Delete sync option on the resource will always override a +Delete sync policy defined in the Application. + ## Resource Deletion With Confirmation Resources such as Namespaces are critical and should not be deleted without confirmation. You can set the `Delete=confirm` @@ -106,6 +150,20 @@ metadata: To confirm the deletion you can use Argo CD UI, CLI or manually apply the `argocd.argoproj.io/deletion-approved: ` annotation to the application. +It is also possible to set this option as a default option on the application level: + +```yaml +apiVersion: argoproj.io/v1alpha1 +kind: Application +spec: + syncPolicy: + syncOptions: + - Delete=confirm +``` + +Note that setting a Delete sync option on the resource will always override a +Delete sync policy defined in the Application. + ## Selective Sync Currently, when syncing using auto sync Argo CD applies every object in the application. diff --git a/gitops-engine/pkg/sync/common/types.go b/gitops-engine/pkg/sync/common/types.go index 30055ff177b0b..fc37a742850d5 100644 --- a/gitops-engine/pkg/sync/common/types.go +++ b/gitops-engine/pkg/sync/common/types.go @@ -20,8 +20,6 @@ const ( // Sync option that disables dry run in resource is missing in the cluster SyncOptionSkipDryRunOnMissingResource = "SkipDryRunOnMissingResource=true" - // Sync option that disables resource pruning - SyncOptionDisablePrune = "Prune=false" // Sync option that disables resource validation SyncOptionsDisableValidation = "Validate=false" // Sync option that enables pruneLast @@ -36,16 +34,18 @@ const ( SyncOptionServerSideApply = "ServerSideApply=true" // Sync option that disables use of --server-side flag instead of client-side SyncOptionDisableServerSideApply = "ServerSideApply=false" - // Sync option that disables resource deletion - SyncOptionDisableDeletion = "Delete=false" // Sync option that sync only out of sync resources SyncOptionApplyOutOfSyncOnly = "ApplyOutOfSyncOnly=true" // Sync option that disables sync only out of sync resources SyncOptionDisableApplyOutOfSyncOnly = "ApplyOutOfSyncOnly=false" - // Sync option that requires confirmation before deleting the resource - SyncOptionDeleteRequireConfirm = "Delete=confirm" - // Sync option that requires confirmation before deleting the resource - SyncOptionPruneRequireConfirm = "Prune=confirm" + // Sync option that controls resource deletion + SyncOptionDelete = "Delete" + // Sync option that controls resource pruning + SyncOptionPrune = "Prune" + // Sync value to confirm a delete or prune operation + SyncValueConfirm = "confirm" + // Sync value to disable a delete or prune operation + SyncValueFalse = "false" // Sync option that enables client-side apply migration SyncOptionClientSideApplyMigration = "ClientSideApplyMigration=true" // Sync option that disables client-side apply migration diff --git a/gitops-engine/pkg/sync/resource/annotations.go b/gitops-engine/pkg/sync/resource/annotations.go index aa9c27cdcb8c1..8c8ade5135cea 100644 --- a/gitops-engine/pkg/sync/resource/annotations.go +++ b/gitops-engine/pkg/sync/resource/annotations.go @@ -14,16 +14,18 @@ type AnnotationGetter interface { // the given key. If the annotation has comma separated values, the returned // list will contain all deduped values. func GetAnnotationCSVs(obj AnnotationGetter, key string) []string { - // may for de-duping - valuesToBool := make(map[string]bool) + // map for de-duping + seen := make(map[string]bool) + var values []string for _, item := range strings.Split(obj.GetAnnotations()[key], ",") { val := strings.TrimSpace(item) - if val != "" { - valuesToBool[val] = true + if val == "" { + continue } - } - var values []string - for val := range valuesToBool { + if seen[val] { + continue + } + seen[val] = true values = append(values, val) } return values @@ -39,3 +41,16 @@ func HasAnnotationOption(obj AnnotationGetter, key, val string) bool { } return false } + +// GetAnnotationOptionValue will return the value of an option inside the +// annotation defined as the given key. +// This function only support options that are defined as key=value and not standalone. +func GetAnnotationOptionValue(obj AnnotationGetter, annotation, optionKey string) *string { + prefix := optionKey + "=" + for _, item := range GetAnnotationCSVs(obj, annotation) { + if val, found := strings.CutPrefix(item, prefix); found { + return new(val) + } + } + return nil +} diff --git a/gitops-engine/pkg/sync/resource/annotations_test.go b/gitops-engine/pkg/sync/resource/annotations_test.go index 397c87eba9afd..16a9cb58fd388 100644 --- a/gitops-engine/pkg/sync/resource/annotations_test.go +++ b/gitops-engine/pkg/sync/resource/annotations_test.go @@ -36,6 +36,38 @@ func TestHasAnnotationOption(t *testing.T) { } } +func TestGetAnnotationOptionValue(t *testing.T) { + type args struct { + obj *unstructured.Unstructured + key string + val string + } + tests := []struct { + name string + args args + want *string + }{ + {"Nil", args{testingutils.NewPod(), "foo", "bar"}, nil}, + {"Empty", args{example(""), "foo", "bar"}, nil}, + {"Standalone", args{example("bar"), "foo", "bar"}, nil}, + {"Single", args{example("bar=baz"), "foo", "bar"}, new("baz")}, + {"DeDup", args{example("bar=baz1,bar=baz2"), "foo", "bar"}, new("baz1")}, + {"Double", args{example("bar=qux,baz=quux"), "foo", "baz"}, new("quux")}, + {"Spaces", args{example("bar=baz "), "foo", "bar"}, new("baz")}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GetAnnotationOptionValue(tt.args.obj, tt.args.key, tt.args.val) + if tt.want == nil { + assert.Nil(t, got) + } else { + assert.NotNil(t, got) + assert.Equal(t, *tt.want, *got) + } + }) + } +} + func example(val string) *unstructured.Unstructured { return testingutils.Annotate(testingutils.NewPod(), "foo", val) } diff --git a/gitops-engine/pkg/sync/sync_context.go b/gitops-engine/pkg/sync/sync_context.go index 23fa7ecd226be..1a2d1b37dde44 100644 --- a/gitops-engine/pkg/sync/sync_context.go +++ b/gitops-engine/pkg/sync/sync_context.go @@ -118,6 +118,13 @@ func WithPrune(prune bool) SyncOpt { } } +// WithDefaultPruneOption specifies the application level Prune option +func WithDefaultPruneOption(defaultPruneOption *string) SyncOpt { + return func(ctx *syncContext) { + ctx.defaultPruneOption = defaultPruneOption + } +} + // WithPruneConfirmed specifies if prune is confirmed for resources that require confirmation func WithPruneConfirmed(confirmed bool) SyncOpt { return func(ctx *syncContext) { @@ -314,6 +321,28 @@ func groupDiffResults(diffResultList *diff.DiffResultList) map[kubeutil.Resource return modifiedResources } +func objRequiresPruneConfirmation(obj *unstructured.Unstructured, defaultPruneOption *string) bool { + var pruneOptionValue *string + if obj != nil { + pruneOptionValue = resourceutil.GetAnnotationOptionValue(obj, common.AnnotationSyncOptions, common.SyncOptionPrune) + } + if pruneOptionValue == nil { + pruneOptionValue = defaultPruneOption + } + return pruneOptionValue != nil && *pruneOptionValue == common.SyncValueConfirm +} + +func isPruningDisabled(obj *unstructured.Unstructured, defaultPruneOption *string) bool { + var pruneOptionValue *string + if obj != nil { + pruneOptionValue = resourceutil.GetAnnotationOptionValue(obj, common.AnnotationSyncOptions, common.SyncOptionPrune) + } + if pruneOptionValue == nil { + pruneOptionValue = defaultPruneOption + } + return pruneOptionValue != nil && *pruneOptionValue == common.SyncValueFalse +} + const ( crdReadinessTimeout = time.Duration(3) * time.Second ) @@ -370,6 +399,7 @@ type syncContext struct { pruneLast bool prunePropagationPolicy *metav1.DeletionPropagation pruneConfirmed bool + defaultPruneOption *string clientSideApplyMigrationManager string enableClientSideApplyMigration bool @@ -429,7 +459,7 @@ func (sc *syncContext) setRunningPhase(tasks syncTasks, isPendingDeletion bool) if !sc.pruneConfirmed { tasksToPrune := tasks.Filter(func(task *syncTask) bool { - return task.isPrune() && resourceutil.HasAnnotationOption(task.liveObj, common.AnnotationSyncOptions, common.SyncOptionPruneRequireConfirm) + return task.isPrune() && objRequiresPruneConfirmation(task.liveObj, sc.defaultPruneOption) }) if len(tasksToPrune) > 0 { @@ -1405,7 +1435,7 @@ func (sc *syncContext) applyObject(t *syncTask, dryRun, validate bool) (common.R func (sc *syncContext) pruneObject(liveObj *unstructured.Unstructured, prune, dryRun bool) (common.ResultCode, string) { if !prune { return common.ResultCodePruneSkipped, "ignored (requires pruning)" - } else if resourceutil.HasAnnotationOption(liveObj, common.AnnotationSyncOptions, common.SyncOptionDisablePrune) { + } else if isPruningDisabled(liveObj, sc.defaultPruneOption) { return common.ResultCodePruneSkipped, "ignored (no prune)" } if dryRun { @@ -1568,7 +1598,7 @@ func (sc *syncContext) runTasks(tasks syncTasks, dryRun bool) runState { { if !sc.pruneConfirmed { for _, task := range pruneTasks { - if resourceutil.HasAnnotationOption(task.liveObj, common.AnnotationSyncOptions, common.SyncOptionPruneRequireConfirm) { + if objRequiresPruneConfirmation(task.liveObj, sc.defaultPruneOption) { sc.log.WithValues("task", task).Info("Prune requires confirmation") return pending } diff --git a/gitops-engine/pkg/sync/sync_context_test.go b/gitops-engine/pkg/sync/sync_context_test.go index e656bdbb54968..de48b9de26807 100644 --- a/gitops-engine/pkg/sync/sync_context_test.go +++ b/gitops-engine/pkg/sync/sync_context_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" "reflect" @@ -938,11 +939,10 @@ func TestDoNotSyncOrPruneHooks(t *testing.T) { assert.Equal(t, synccommon.OperationSucceeded, phase) } -// make sure that we do not prune resources with Prune=false -func TestDoNotPrunePruneFalse(t *testing.T) { +func TestDoNotPruneAppLevelPruneFalse(t *testing.T) { syncCtx := newTestSyncCtx(nil, WithOperationSettings(false, true, false, false)) pod := testingutils.NewPod() - pod.SetAnnotations(map[string]string{synccommon.AnnotationSyncOptions: "Prune=false"}) + syncCtx.defaultPruneOption = new("false") pod.SetNamespace(testingutils.FakeArgoCDNamespace) syncCtx.resources = groupResources(ReconciliationResult{ Live: []*unstructured.Unstructured{pod}, @@ -963,10 +963,70 @@ func TestDoNotPrunePruneFalse(t *testing.T) { assert.Equal(t, synccommon.OperationSucceeded, phase) } -func TestPruneConfirm(t *testing.T) { +func TestDoNotPruneResourceLevelPruneFalse(t *testing.T) { + // Check that the defaultPruneOption does not override the resource level Prune=false annotation + for _, defaultPruneOption := range []*string{nil, new("true"), new("false")} { + syncCtx := newTestSyncCtx(nil, WithOperationSettings(false, true, false, false)) + pod := testingutils.NewPod() + + pod.SetAnnotations(map[string]string{synccommon.AnnotationSyncOptions: "Prune=false"}) + + pod.SetNamespace(testingutils.FakeArgoCDNamespace) + syncCtx.resources = groupResources(ReconciliationResult{ + Live: []*unstructured.Unstructured{pod}, + Target: []*unstructured.Unstructured{nil}, + }) + t.Run(fmt.Sprintf("Check resource level override defaultPruneOption=%v", defaultPruneOption), func(t *testing.T) { + syncCtx.defaultPruneOption = defaultPruneOption + syncCtx.Sync() + phase, _, resources := syncCtx.GetState() + + assert.Equal(t, synccommon.OperationSucceeded, phase) + assert.Len(t, resources, 1) + assert.Equal(t, synccommon.ResultCodePruneSkipped, resources[0].Status) + assert.Equal(t, "ignored (no prune)", resources[0].Message) + }) + } +} + +func TestPruneConfirmResourceLevel(t *testing.T) { + // Check that the resource level Prune=confirm annotation overrides the defaultPruneOption + for _, defaultPruneOption := range []*string{nil, new("true"), new("false"), new("confirm")} { + syncCtx := newTestSyncCtx(nil, WithOperationSettings(false, true, false, false)) + pod := testingutils.NewPod() + pod.SetAnnotations(map[string]string{synccommon.AnnotationSyncOptions: "Prune=confirm"}) + pod.SetNamespace(testingutils.FakeArgoCDNamespace) + syncCtx.resources = groupResources(ReconciliationResult{ + Live: []*unstructured.Unstructured{pod}, + Target: []*unstructured.Unstructured{nil}, + }) + + t.Run(fmt.Sprintf("Check resource level override defaultPruneOption=%v", defaultPruneOption), func(t *testing.T) { + syncCtx.defaultPruneOption = defaultPruneOption + + syncCtx.Sync() + phase, msg, resources := syncCtx.GetState() + + assert.Equal(t, synccommon.OperationRunning, phase) + assert.Empty(t, resources) + assert.Equal(t, "waiting for pruning confirmation of /Pod/my-pod", msg) + + syncCtx.pruneConfirmed = true + syncCtx.Sync() + + phase, _, resources = syncCtx.GetState() + assert.Equal(t, synccommon.OperationSucceeded, phase) + assert.Len(t, resources, 1) + assert.Equal(t, synccommon.ResultCodePruned, resources[0].Status) + assert.Equal(t, "pruned", resources[0].Message) + }) + } +} + +func TestPruneConfirmAppLevel(t *testing.T) { syncCtx := newTestSyncCtx(nil, WithOperationSettings(false, true, false, false)) pod := testingutils.NewPod() - pod.SetAnnotations(map[string]string{synccommon.AnnotationSyncOptions: "Prune=confirm"}) + syncCtx.defaultPruneOption = new("confirm") pod.SetNamespace(testingutils.FakeArgoCDNamespace) syncCtx.resources = groupResources(ReconciliationResult{ Live: []*unstructured.Unstructured{pod}, diff --git a/pkg/apis/application/v1alpha1/types.go b/pkg/apis/application/v1alpha1/types.go index ddde1b77ecc44..70b42e0dce880 100644 --- a/pkg/apis/application/v1alpha1/types.go +++ b/pkg/apis/application/v1alpha1/types.go @@ -1461,6 +1461,18 @@ func (o SyncOptions) HasOption(option string) bool { return slices.Contains(o, option) } +// GetOptionValue returns true if the list of sync options contains given option +// This function only support options that are defined as key=value and not standalone. +func (o SyncOptions) GetOptionValue(optionKey string) *string { + prefix := optionKey + "=" + for _, i := range o { + if val, found := strings.CutPrefix(i, prefix); found { + return new(val) + } + } + return nil +} + type ManagedNamespaceMetadata struct { Labels map[string]string `json:"labels,omitempty" protobuf:"bytes,1,opt,name=labels"` Annotations map[string]string `json:"annotations,omitempty" protobuf:"bytes,2,opt,name=annotations"` diff --git a/test/e2e/app_management_test.go b/test/e2e/app_management_test.go index 5ff31d0a7f7e7..b73b7234eed21 100644 --- a/test/e2e/app_management_test.go +++ b/test/e2e/app_management_test.go @@ -1755,7 +1755,7 @@ func TestPermissionDeniedWithNegatedServer(t *testing.T) { } // 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) { +func TestSyncOptionPruneFalseResourceLevel(t *testing.T) { Given(t). Path("two-nice-pods"). When(). @@ -1776,6 +1776,57 @@ func TestSyncOptionPruneFalse(t *testing.T) { Expect(ResourceSyncStatusIs("Pod", "pod-1", SyncStatusCodeOutOfSync)) } +func TestSyncOptionPruneFalseAppLevel(t *testing.T) { + Given(t). + Path("two-nice-pods"). + When(). + CreateApp(). + PatchApp(`[{ + "op": "add", + "path": "/spec/syncPolicy", + "value": { "syncOptions": ["Prune=false"] } + }]`). + Sync(). + Then(). + Expect(OperationPhaseIs(OperationSucceeded)). + Expect(SyncStatusIs(SyncStatusCodeSynced)). + When(). + DeleteFile("pod-1.yaml"). + Refresh(RefreshTypeHard). + IgnoreErrors(). + Sync(). + Then(). + Expect(OperationPhaseIs(OperationSucceeded)). + Expect(SyncStatusIs(SyncStatusCodeOutOfSync)). + Expect(ResourceSyncStatusIs("Pod", "pod-1", SyncStatusCodeOutOfSync)) +} + +func TestSyncOptionPruneFalseResourceOverride(t *testing.T) { + Given(t). + Path("two-nice-pods"). + When(). + CreateApp(). + PatchApp(`[{ + "op": "add", + "path": "/spec/syncPolicy", + "value": { "syncOptions": ["Prune=true"] } + }]`). + PatchFile("pod-1.yaml", `[{"op": "add", "path": "/metadata/annotations", "value": {"argocd.argoproj.io/sync-options": "Prune=false"}}]`). + Sync(). + Then(). + Expect(OperationPhaseIs(OperationSucceeded)). + Expect(SyncStatusIs(SyncStatusCodeSynced)). + When(). + DeleteFile("pod-1.yaml"). + Refresh(RefreshTypeHard). + IgnoreErrors(). + Sync(). + Then(). + Expect(OperationPhaseIs(OperationSucceeded)). + Expect(SyncStatusIs(SyncStatusCodeOutOfSync)). + Expect(ResourceSyncStatusIs("Pod", "pod-1", SyncStatusCodeOutOfSync)) +} + // make sure that if we have an invalid manifest, we can add it if we disable validation, we get a server error rather than a client error func TestSyncOptionValidateFalse(t *testing.T) { Given(t). @@ -3115,6 +3166,41 @@ func TestDeletionConfirmation(t *testing.T) { Then().Expect(DoesNotExist()) } +func TestDeletionConfirmationAppLevel(t *testing.T) { + ctx := Given(t) + ctx. + And(func() { + _, err := fixture.KubeClientset.CoreV1().ConfigMaps(ctx.DeploymentNamespace()).Create( + t.Context(), &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-configmap", + Annotations: map[string]string{ + common.AnnotationKeyAppInstance: fmt.Sprintf("%s:/ConfigMap:%s/test-configmap", ctx.AppName(), ctx.DeploymentNamespace()), + }, + }, + }, metav1.CreateOptions{}) + require.NoError(t, err) + }). + Path(guestbookPath). + Async(true). + When(). + CreateFromFile(func(app *Application) { + app.Spec.SyncPolicy = &SyncPolicy{SyncOptions: []string{"Delete=confirm", "Prune=confirm"}} + }).Sync(). + Then().ExpectConsistently(OperationPhaseIs(OperationRunning), time.Second, 5*time.Second). + When().ConfirmDeletion(). + Then().Expect(OperationPhaseIs(OperationSucceeded)). + Expect(SyncStatusIs(SyncStatusCodeSynced)). + Expect(HealthIs(health.HealthStatusHealthy)). + When().Delete(true). + Then(). + ExpectConsistently(App(func(app *Application) bool { + return app.DeletionTimestamp != nil + }), time.Second, 5*time.Second). + When().ConfirmDeletion(). + Then().Expect(DoesNotExist()) +} + func TestLastTransitionTimeUnchangedError(t *testing.T) { // Ensure that, if the health status hasn't changed, the lastTransitionTime is not updated. diff --git a/ui/src/app/applications/components/application-sync-options/application-sync-options.tsx b/ui/src/app/applications/components/application-sync-options/application-sync-options.tsx index 51408017c61b5..d7bbe3296b5f5 100644 --- a/ui/src/app/applications/components/application-sync-options/application-sync-options.tsx +++ b/ui/src/app/applications/components/application-sync-options/application-sync-options.tsx @@ -118,11 +118,13 @@ export interface SyncFlags { const syncOptions: Array<(props: ApplicationSyncOptionProps) => React.ReactNode> = [ props => booleanOption('Validate', 'Skip Schema Validation', false, props, true), props => booleanOption('CreateNamespace', 'Auto-Create Namespace', false, props, false), - props => booleanOption('PruneLast', 'Prune Last', false, props, false), props => booleanOption('ApplyOutOfSyncOnly', 'Apply Out of Sync Only', false, props, false), props => booleanOption('RespectIgnoreDifferences', 'Respect Ignore Differences', false, props, false), props => booleanOption('ServerSideApply', 'Server-Side Apply', false, props, false), - props => selectOption('PrunePropagationPolicy', 'Prune Propagation Policy', 'foreground', ['foreground', 'background', 'orphan'], props) + props => booleanOption('PruneLast', 'Prune Last', false, props, false), + props => selectOption('PrunePropagationPolicy', 'Prune Propagation Policy', 'foreground', ['foreground', 'background', 'orphan'], props), + props => selectOption('Prune', 'Prune', 'true', ['true', 'false', 'confirm'], props), + props => selectOption('Delete', 'Delete', 'true', ['true', 'false', 'confirm'], props) ]; const optionStyle = {marginTop: '0.5em'};