diff --git a/controller/appcontroller.go b/controller/appcontroller.go index 9435a1ae4bc3c..987dd601e32c6 100644 --- a/controller/appcontroller.go +++ b/controller/appcontroller.go @@ -1165,8 +1165,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 6a331bd50a87a..2b91fcd4800aa 100644 --- a/controller/appcontroller_test.go +++ b/controller/appcontroller_test.go @@ -2680,6 +2680,19 @@ func Test_syncDeleteOption(t *testing.T) { cmObj.SetAnnotations(map[string]string{"helm.sh/resource-policy": "keep"}) assert.False(t, ctrl.shouldBeDeleted(app, cmObj)) }) + + app.Spec.SyncPolicy.SyncOptions = []string{"Delete=false"} + t.Run("delete set on the app level", func(t *testing.T) { + cmObj := kube.MustToUnstructured(&cm) + cmObj.SetAnnotations(map[string]string{}) + assert.False(t, ctrl.shouldBeDeleted(app, cmObj)) + }) + + t.Run("delete should be overridden on the resource", func(t *testing.T) { + cmObj := kube.MustToUnstructured(&cm) + cmObj.SetAnnotations(map[string]string{"argocd.argoproj.io/sync-options": "Delete=foo"}) + assert.True(t, ctrl.shouldBeDeleted(app, cmObj)) + }) } func TestAddControllerNamespace(t *testing.T) { diff --git a/controller/state.go b/controller/state.go index 6e5f9d67e0687..b211a4b2088f7 100644 --- a/controller/state.go +++ b/controller/state.go @@ -853,15 +853,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), + 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)) @@ -1053,6 +1052,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 2fd6112979c6f..0629c62d31fcd 100644 --- a/controller/state_test.go +++ b/controller/state_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "os" + "strings" "testing" "time" @@ -1848,3 +1849,72 @@ func TestCompareAppState_DoesNotCallUpdateRevisionForPaths_ForOCI(t *testing.T) _, _, _, err := ctrl.appStateManager.GetRepoObjs(t.Context(), app, sources, "abc123", []string{"123456"}, false, false, false, &defaultProj, false) require.NoError(t, err) } + +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 resource", + 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 fd4b5b8b4f794..58ea4cdd6e286 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 81da6339b1cc8..90bc517202df3 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) @@ -34,6 +48,21 @@ metadata: To confirm the pruning 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: + - 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: @@ -87,6 +116,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` @@ -101,6 +145,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 00976ff5d1f4b..059889246847a 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..c4e60ccffc011 100644 --- a/gitops-engine/pkg/sync/resource/annotations.go +++ b/gitops-engine/pkg/sync/resource/annotations.go @@ -2,6 +2,8 @@ package resource import ( "strings" + + "k8s.io/utils/ptr" ) // AnnotationGetter defines the operations required to inspect if a resource @@ -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 strings.HasPrefix(item, prefix) { + return ptr.To(strings.TrimPrefix(item, prefix)) + } + } + return nil +} diff --git a/gitops-engine/pkg/sync/resource/annotations_test.go b/gitops-engine/pkg/sync/resource/annotations_test.go index f024df192e4a9..d74f4ecaff1e7 100644 --- a/gitops-engine/pkg/sync/resource/annotations_test.go +++ b/gitops-engine/pkg/sync/resource/annotations_test.go @@ -5,6 +5,7 @@ import ( "github.com/stretchr/testify/assert" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/utils/ptr" testingutils "github.com/argoproj/gitops-engine/pkg/utils/testing" ) @@ -36,6 +37,32 @@ 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"}, ptr.To("baz")}, + {"DeDup", args{example("bar=baz1,bar=baz2"), "foo", "bar"}, ptr.To("baz1")}, + {"Double", args{example("bar=qux,baz=quux"), "foo", "baz"}, ptr.To("quux")}, + {"Spaces", args{example("bar=baz "), "foo", "bar"}, ptr.To("baz")}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, GetAnnotationOptionValue(tt.args.obj, tt.args.key, tt.args.val)) + }) + } +} + 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 675e1d04e68e8..d27952eb71e0a 100644 --- a/gitops-engine/pkg/sync/sync_context.go +++ b/gitops-engine/pkg/sync/sync_context.go @@ -115,6 +115,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) { @@ -367,6 +374,7 @@ type syncContext struct { pruneLast bool prunePropagationPolicy *metav1.DeletionPropagation pruneConfirmed bool + defaultPruneOption *string clientSideApplyMigrationManager string enableClientSideApplyMigration bool @@ -1221,7 +1229,13 @@ 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) { + } + + pruneOptionValue := resourceutil.GetAnnotationOptionValue(liveObj, common.AnnotationSyncOptions, common.SyncOptionPrune) + if pruneOptionValue == nil { + pruneOptionValue = sc.defaultPruneOption + } + if pruneOptionValue != nil && *pruneOptionValue == common.SyncValueFalse { return common.ResultCodePruneSkipped, "ignored (no prune)" } if dryRun { @@ -1406,7 +1420,11 @@ func (sc *syncContext) runTasks(tasks syncTasks, dryRun bool) runState { if !sc.pruneConfirmed { var resources []string for _, task := range pruneTasks { - if resourceutil.HasAnnotationOption(task.liveObj, common.AnnotationSyncOptions, common.SyncOptionPruneRequireConfirm) { + pruneOptionValue := resourceutil.GetAnnotationOptionValue(task.liveObj, common.AnnotationSyncOptions, common.SyncOptionPrune) + if pruneOptionValue == nil { + pruneOptionValue = sc.defaultPruneOption + } + if pruneOptionValue != nil && *pruneOptionValue == common.SyncValueConfirm { resources = append(resources, fmt.Sprintf("%s/%s/%s", task.obj().GetAPIVersion(), task.obj().GetKind(), task.name())) } } diff --git a/gitops-engine/pkg/sync/sync_context_test.go b/gitops-engine/pkg/sync/sync_context_test.go index 59f4c52ebd444..04af2a7605956 100644 --- a/gitops-engine/pkg/sync/sync_context_test.go +++ b/gitops-engine/pkg/sync/sync_context_test.go @@ -8,12 +8,14 @@ import ( "net/http" "net/http/httptest" "reflect" + "strconv" "strings" "testing" "time" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/utils/ptr" "github.com/go-logr/logr" "github.com/stretchr/testify/assert" @@ -813,27 +815,88 @@ func TestDoNotSyncOrPruneHooks(t *testing.T) { // make sure that we do not prune resources with Prune=false func TestDoNotPrunePruneFalse(t *testing.T) { - 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}, - }) + for _, appLevel := range []bool{true, false} { + t.Run("appLevel="+strconv.FormatBool(appLevel), func(t *testing.T) { + syncCtx := newTestSyncCtx(nil, WithOperationSettings(false, true, false, false)) + pod := testingutils.NewPod() + if appLevel { + syncCtx.defaultPruneOption = ptr.To("false") + } else { + 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}, + }) - syncCtx.Sync() - phase, _, resources := syncCtx.GetState() + 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) + 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) - syncCtx.Sync() + syncCtx.Sync() - phase, _, _ = syncCtx.GetState() - assert.Equal(t, synccommon.OperationSucceeded, phase) + phase, _, _ = syncCtx.GetState() + assert.Equal(t, synccommon.OperationSucceeded, phase) + + // test that we can still not prune if prune is disabled on the app level + syncCtx.defaultPruneOption = ptr.To("true") + pod.SetAnnotations(map[string]string{synccommon.AnnotationSyncOptions: "Prune=true"}) + 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) + + syncCtx.Sync() + + phase, _, _ = syncCtx.GetState() + assert.Equal(t, synccommon.OperationSucceeded, phase) + }) + } +} + +// make sure that we need confirmation to prune with Prune=confirm +func TestPruneConfirm(t *testing.T) { + for _, appLevel := range []bool{true, false} { + t.Run("appLevel="+strconv.FormatBool(appLevel), func(t *testing.T) { + syncCtx := newTestSyncCtx(nil, WithOperationSettings(false, true, false, false)) + pod := testingutils.NewPod() + if appLevel { + syncCtx.defaultPruneOption = ptr.To("confirm") + } else { + 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}, + }) + + 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 v1/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) + }) + } } // // make sure Validate=false means we don't validate diff --git a/pkg/apis/application/v1alpha1/types.go b/pkg/apis/application/v1alpha1/types.go index ef23b6f019b44..004d4a58dddc1 100644 --- a/pkg/apis/application/v1alpha1/types.go +++ b/pkg/apis/application/v1alpha1/types.go @@ -40,6 +40,7 @@ import ( "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" "k8s.io/client-go/tools/clientcmd/api" + "k8s.io/utils/ptr" "sigs.k8s.io/yaml" "github.com/argoproj/argo-cd/v3/util/rbac" @@ -1436,6 +1437,18 @@ func (o SyncOptions) HasOption(option string) bool { return false } +// 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 strings.HasPrefix(i, prefix) { + return ptr.To(strings.TrimPrefix(i, prefix)) + } + } + 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 638b1c2c8db72..21bfcaf953838 100644 --- a/test/e2e/app_management_test.go +++ b/test/e2e/app_management_test.go @@ -1730,6 +1730,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) { + // Resource level Given(t). Path("two-nice-pods"). When(). @@ -1748,6 +1749,55 @@ func TestSyncOptionPruneFalse(t *testing.T) { Expect(OperationPhaseIs(OperationSucceeded)). Expect(SyncStatusIs(SyncStatusCodeOutOfSync)). Expect(ResourceSyncStatusIs("Pod", "pod-1", SyncStatusCodeOutOfSync)) + + // App level + 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)) + + // Also check that another setting on a resource level correctly override app level + 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 @@ -3087,6 +3137,42 @@ func TestDeletionConfirmation(t *testing.T) { Then().Expect(DoesNotExist()) } +func TestDeletionConfirmationAppLevel(t *testing.T) { + ctx := Given(t) + ctx. + And(func() { + _, err := fixture.KubeClientset.CoreV1().ConfigMaps(fixture.DeploymentNamespace()).Create( + t.Context(), &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-configmap", + Labels: map[string]string{ + common.LabelKeyAppInstance: ctx.AppName(), + }, + }, + }, metav1.CreateOptions{}) + require.NoError(t, err) + }). + Path(guestbookPath). + Async(true). + When(). + CreateApp().Then().When(). + PatchApp(`[{ + "op": "add", + "path": "/spec/syncPolicy", + "value": { "syncOptions": ["Delete=confirm", "Prune=confirm"] } + }]`).Sync(). + Then().Expect(OperationPhaseIs(OperationRunning)). + When().ConfirmDeletion(). + Then().Expect(OperationPhaseIs(OperationSucceeded)). + When().Delete(true). + Then(). + And(func(app *Application) { + assert.NotNil(t, app.DeletionTimestamp) + }). + 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 7621ffcfdd274..d6350ff02d18e 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 @@ -120,6 +120,8 @@ export const ApplicationSyncOptions = (props: ApplicationSyncOptionProps) => ( {syncWithReplaceAllowed => (syncWithReplaceAllowed && (
+ {selectOption('Prune', 'Prune', 'true', ['true', 'false', 'confirm'], props)} + {selectOption('Delete', 'Delete', 'true', ['true', 'false', 'confirm'], props)} {booleanOption('Replace', 'Replace', false, props, false, REPLACE_WARNING)}
)) ||