diff --git a/cmd/argocd/commands/app.go b/cmd/argocd/commands/app.go index 89398f55be110..4802781162b0f 100644 --- a/cmd/argocd/commands/app.go +++ b/cmd/argocd/commands/app.go @@ -100,6 +100,22 @@ func NewApplicationCommand(clientOpts *argocdclient.ClientOptions) *cobra.Comman return command } +type applicationStatusOpts struct { + client apiclient.Client + appName string + timeout uint + watch watchOpts + selectedResources []argoappv1.SyncOperationResource +} + +type watchOpts struct { + sync bool + health bool + operation bool + suspended bool + delete bool +} + // NewApplicationCreateCommand returns a new instance of an `argocd app create` command func NewApplicationCreateCommand(clientOpts *argocdclient.ClientOptions) *cobra.Command { var ( @@ -986,6 +1002,8 @@ func NewApplicationDeleteCommand(clientOpts *argocdclient.ClientOptions) *cobra. cascade bool noPrompt bool propagationPolicy string + wait bool + timeout uint ) var command = &cobra.Command{ Use: "delete APPNAME", @@ -1011,6 +1029,7 @@ func NewApplicationDeleteCommand(clientOpts *argocdclient.ClientOptions) *cobra. if c.Flag("cascade").Changed { appDeleteReq.Cascade = &cascade } + if c.Flag("propagation-policy").Changed { appDeleteReq.PropagationPolicy = &propagationPolicy } @@ -1037,6 +1056,17 @@ func NewApplicationDeleteCommand(clientOpts *argocdclient.ClientOptions) *cobra. if lowercaseAnswer == "y" || lowercaseAnswer == "yes" { _, err := appIf.Delete(context.Background(), &appDeleteReq) errors.CheckError(err) + + if wait { + appStatusOpts := applicationStatusOpts{ + client: argocdclient.NewClientOrDie(clientOpts), + appName: appName, + timeout: timeout, + watch: watchOpts{delete: true}, + } + _, err := waitOnApplicationStatus(appStatusOpts) + errors.CheckError(err) + } } else { fmt.Println("The command to delete '" + appName + "' was cancelled.") } @@ -1050,6 +1080,8 @@ func NewApplicationDeleteCommand(clientOpts *argocdclient.ClientOptions) *cobra. command.Flags().BoolVar(&cascade, "cascade", true, "Perform a cascaded deletion of all application resources") command.Flags().StringVarP(&propagationPolicy, "propagation-policy", "p", "foreground", "Specify propagation policy for deletion of application's resources. One of: foreground|background") command.Flags().BoolVarP(&noPrompt, "yes", "y", false, "Turn off prompting to confirm cascaded deletion of application resources") + command.Flags().BoolVar(&wait, "wait", false, "Will cause the command to only return once the application has been deleted") + command.Flags().UintVar(&timeout, "timeout", defaultCheckTimeoutSeconds, "Time to spend waiting for application to delete, default is no timeout") return command } @@ -1216,13 +1248,10 @@ func parseSelectedResources(resources []string) []argoappv1.SyncOperationResourc // NewApplicationWaitCommand returns a new instance of an `argocd app wait` command func NewApplicationWaitCommand(clientOpts *argocdclient.ClientOptions) *cobra.Command { var ( - watchSync bool - watchHealth bool - watchSuspended bool - watchOperations bool - timeout uint - selector string - resources []string + watch watchOpts + timeout uint + selector string + resources []string ) var command = &cobra.Command{ Use: "wait [APPNAME.. | -l selector]", @@ -1240,16 +1269,24 @@ func NewApplicationWaitCommand(clientOpts *argocdclient.ClientOptions) *cobra.Co c.HelpFunc()(c, args) os.Exit(1) } - if !watchSync && !watchHealth && !watchOperations && !watchSuspended { - watchSync = true - watchHealth = true - watchOperations = true - watchSuspended = false + + appStatusOpts := applicationStatusOpts{ + client: argocdclient.NewClientOrDie(clientOpts), + timeout: timeout, + selectedResources: parseSelectedResources(resources), + } + if !watch.sync && !watch.health && !watch.operation && !watch.suspended && !watch.delete { + appStatusOpts.watch = watchOpts{ + sync: true, + health: true, + operation: true, + } + } else { + appStatusOpts.watch = watch } - selectedResources := parseSelectedResources(resources) + appNames := args - acdClient := argocdclient.NewClientOrDie(clientOpts) - closer, appIf := acdClient.NewApplicationClientOrDie() + closer, appIf := appStatusOpts.client.NewApplicationClientOrDie() defer argoio.Close(closer) if selector != "" { list, err := appIf.List(context.Background(), &applicationpkg.ApplicationQuery{Selector: selector}) @@ -1259,17 +1296,19 @@ func NewApplicationWaitCommand(clientOpts *argocdclient.ClientOptions) *cobra.Co } } for _, appName := range appNames { - _, err := waitOnApplicationStatus(acdClient, appName, timeout, watchSync, watchHealth, watchOperations, watchSuspended, selectedResources) + appStatusOpts.appName = appName + _, err := waitOnApplicationStatus(appStatusOpts) errors.CheckError(err) } }, } - command.Flags().BoolVar(&watchSync, "sync", false, "Wait for sync") - command.Flags().BoolVar(&watchHealth, "health", false, "Wait for health") - command.Flags().BoolVar(&watchSuspended, "suspended", false, "Wait for suspended") + command.Flags().BoolVar(&watch.sync, "sync", false, "Wait for sync") + command.Flags().BoolVar(&watch.health, "health", false, "Wait for health") + command.Flags().BoolVar(&watch.suspended, "suspended", false, "Wait for suspended") command.Flags().StringVarP(&selector, "selector", "l", "", "Wait for apps by label") command.Flags().StringArrayVar(&resources, "resource", []string{}, fmt.Sprintf("Sync only specific resources as GROUP%sKIND%sNAME. Fields may be blank. This option may be specified repeatedly", resourceFieldDelimiter, resourceFieldDelimiter)) - command.Flags().BoolVar(&watchOperations, "operation", false, "Wait for pending operations") + command.Flags().BoolVar(&watch.operation, "operation", false, "Wait for pending operations") + command.Flags().BoolVar(&watch.delete, "delete", false, "wait for application to be deleted") command.Flags().UintVar(&timeout, "timeout", defaultCheckTimeoutSeconds, "Time out after this many seconds") return command } @@ -1456,7 +1495,14 @@ func NewApplicationSyncCommand(clientOpts *argocdclient.ClientOptions) *cobra.Co errors.CheckError(err) if !async { - app, err := waitOnApplicationStatus(acdClient, appName, timeout, false, false, true, false, selectedResources) + appStatusOpts := applicationStatusOpts{ + client: acdClient, + appName: appName, + timeout: timeout, + watch: watchOpts{operation: true}, + selectedResources: selectedResources, + } + app, err := waitOnApplicationStatus(appStatusOpts) errors.CheckError(err) if !dryRun { @@ -1603,25 +1649,25 @@ func groupResourceStates(app *argoappv1.Application, selectedResources []argoapp return resStates } -func checkResourceStatus(watchSync bool, watchHealth bool, watchOperation bool, watchSuspended bool, healthStatus string, syncStatus string, operationStatus *argoappv1.Operation) bool { +func checkResourceStatus(watch watchOpts, healthStatus string, syncStatus string, operationStatus *argoappv1.Operation) bool { healthCheckPassed := true - if watchSuspended && watchHealth { + if watch.suspended && watch.health { healthCheckPassed = healthStatus == string(health.HealthStatusHealthy) || healthStatus == string(health.HealthStatusSuspended) - } else if watchSuspended { + } else if watch.suspended { healthCheckPassed = healthStatus == string(health.HealthStatusSuspended) - } else if watchHealth { + } else if watch.health { healthCheckPassed = healthStatus == string(health.HealthStatusHealthy) } - synced := !watchSync || syncStatus == string(argoappv1.SyncStatusCodeSynced) - operational := !watchOperation || operationStatus == nil + synced := !watch.sync || syncStatus == string(argoappv1.SyncStatusCodeSynced) + operational := !watch.operation || operationStatus == nil return synced && healthCheckPassed && operational } const waitFormatString = "%s\t%5s\t%10s\t%10s\t%20s\t%8s\t%7s\t%10s\t%s\n" -func waitOnApplicationStatus(acdClient apiclient.Client, appName string, timeout uint, watchSync bool, watchHealth bool, watchOperation bool, watchSuspended bool, selectedResources []argoappv1.SyncOperationResource) (*argoappv1.Application, error) { +func waitOnApplicationStatus(opts applicationStatusOpts) (*argoappv1.Application, error) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -1633,17 +1679,17 @@ func waitOnApplicationStatus(acdClient apiclient.Client, appName string, timeout printFinalStatus := func(app *argoappv1.Application) *argoappv1.Application { var err error if refresh { - conn, appClient := acdClient.NewApplicationClientOrDie() + conn, appClient := opts.client.NewApplicationClientOrDie() refreshType := string(argoappv1.RefreshTypeNormal) - app, err = appClient.Get(context.Background(), &applicationpkg.ApplicationQuery{Name: &appName, Refresh: &refreshType}) + app, err = appClient.Get(context.Background(), &applicationpkg.ApplicationQuery{Name: &opts.appName, Refresh: &refreshType}) errors.CheckError(err) _ = conn.Close() } fmt.Println() - printAppSummaryTable(app, appURL(acdClient, appName), nil) + printAppSummaryTable(app, appURL(opts.client, opts.appName), nil) fmt.Println() - if watchOperation { + if opts.watch.operation { printOperationResult(app.Status.OperationState) } @@ -1656,8 +1702,8 @@ func waitOnApplicationStatus(acdClient apiclient.Client, appName string, timeout return app } - if timeout != 0 { - time.AfterFunc(time.Duration(timeout)*time.Second, func() { + if opts.timeout != 0 { + time.AfterFunc(time.Duration(opts.timeout)*time.Second, func() { cancel() }) } @@ -1666,11 +1712,11 @@ func waitOnApplicationStatus(acdClient apiclient.Client, appName string, timeout _, _ = fmt.Fprintf(w, waitFormatString, "TIMESTAMP", "GROUP", "KIND", "NAMESPACE", "NAME", "STATUS", "HEALTH", "HOOK", "MESSAGE") prevStates := make(map[string]*resourceState) - conn, appClient := acdClient.NewApplicationClientOrDie() + conn, appClient := opts.client.NewApplicationClientOrDie() defer argoio.Close(conn) - app, err := appClient.Get(ctx, &applicationpkg.ApplicationQuery{Name: &appName}) + app, err := appClient.Get(ctx, &applicationpkg.ApplicationQuery{Name: &opts.appName}) errors.CheckError(err) - appEventCh := acdClient.WatchApplicationWithRetry(ctx, appName, app.ResourceVersion) + appEventCh := opts.client.WatchApplicationWithRetry(ctx, opts.appName, app.ResourceVersion) for appEvent := range appEventCh { app = &appEvent.Application @@ -1695,10 +1741,10 @@ func waitOnApplicationStatus(acdClient apiclient.Client, appName string, timeout var selectedResourcesAreReady bool // If selected resources are included, wait only on those resources, otherwise wait on the application as a whole. - if len(selectedResources) > 0 { + if len(opts.selectedResources) > 0 { selectedResourcesAreReady = true - for _, state := range getResourceStates(app, selectedResources) { - resourceIsReady := checkResourceStatus(watchSync, watchHealth, watchOperation, watchSuspended, state.Health, state.Status, appEvent.Application.Operation) + for _, state := range getResourceStates(app, opts.selectedResources) { + resourceIsReady := checkResourceStatus(opts.watch, state.Health, state.Status, appEvent.Application.Operation) if !resourceIsReady { selectedResourcesAreReady = false break @@ -1706,24 +1752,31 @@ func waitOnApplicationStatus(acdClient apiclient.Client, appName string, timeout } } else { // Wait on the application as a whole - selectedResourcesAreReady = checkResourceStatus(watchSync, watchHealth, watchOperation, watchSuspended, string(app.Status.Health.Status), string(app.Status.Sync.Status), appEvent.Application.Operation) + selectedResourcesAreReady = checkResourceStatus(opts.watch, string(app.Status.Health.Status), string(app.Status.Sync.Status), appEvent.Application.Operation) } - if selectedResourcesAreReady && (!operationInProgress || !watchOperation) { + if selectedResourcesAreReady && (!operationInProgress || !opts.watch.operation) && !opts.watch.delete { app = printFinalStatus(app) return app, nil } - newStates := groupResourceStates(app, selectedResources) + newStates := groupResourceStates(app, opts.selectedResources) for _, newState := range newStates { var doPrint bool stateKey := newState.Key() if prevState, found := prevStates[stateKey]; found { - if watchHealth && prevState.Health != string(health.HealthStatusUnknown) && prevState.Health != string(health.HealthStatusDegraded) && newState.Health == string(health.HealthStatusDegraded) { + if opts.watch.health && prevState.Health != string(health.HealthStatusUnknown) && prevState.Health != string(health.HealthStatusDegraded) && newState.Health == string(health.HealthStatusDegraded) { _ = printFinalStatus(app) - return nil, fmt.Errorf("application '%s' health state has transitioned from %s to %s", appName, prevState.Health, newState.Health) + return nil, fmt.Errorf("application '%s' health state has transitioned from %s to %s", opts.appName, prevState.Health, newState.Health) + } + if opts.watch.delete { + if newState.Health == string(health.HealthStatusMissing) { + newState.Message = strings.Replace(newState.Message, "created", "deleted", 1) + doPrint = prevState.Merge(newState) + } + } else { + doPrint = prevState.Merge(newState) } - doPrint = prevState.Merge(newState) } else { prevStates[stateKey] = newState doPrint = true @@ -1733,9 +1786,18 @@ func waitOnApplicationStatus(acdClient apiclient.Client, appName string, timeout } } _ = w.Flush() + if opts.watch.delete { + _, err := appClient.Get(context.Background(), &applicationpkg.ApplicationQuery{Name: &opts.appName}) + if err != nil { + if strings.HasSuffix(err.Error(), "not found") { + return nil, nil + } + } + } + } _ = printFinalStatus(app) - return nil, fmt.Errorf("timed out (%ds) waiting for app %q match desired state", timeout, appName) + return nil, fmt.Errorf("timed out (%ds) waiting for app %q match desired state", opts.timeout, opts.appName) } // setParameterOverrides updates an existing or appends a new parameter override in the application @@ -1913,7 +1975,12 @@ func NewApplicationRollbackCommand(clientOpts *argocdclient.ClientOptions) *cobr }) errors.CheckError(err) - _, err = waitOnApplicationStatus(acdClient, appName, timeout, false, false, true, false, nil) + _, err = waitOnApplicationStatus(applicationStatusOpts{ + client: acdClient, + appName: appName, + timeout: timeout, + watch: watchOpts{operation: true}, + }) errors.CheckError(err) }, } diff --git a/test/e2e/app_deletion_test.go b/test/e2e/app_deletion_test.go index 3e85fb7afbe9a..634cf92d64a07 100644 --- a/test/e2e/app_deletion_test.go +++ b/test/e2e/app_deletion_test.go @@ -41,3 +41,18 @@ func TestDeletingAppStuckInSync(t *testing.T) { // delete is successful Expect(DoesNotExist()) } + +func TestApplicationDeleteWait(t *testing.T) { + Given(t). + Path(guestbookPath). + When(). + CreateFromFile(func(app *Application) { + app.Spec.SyncPolicy = &SyncPolicy{Automated: &SyncPolicyAutomated{}} + }). + Then(). + Expect(SyncStatusIs(SyncStatusCodeSynced)). + When(). + DeleteWait(). + Then(). + Expect(DoesNotExist()) +} diff --git a/test/e2e/fixture/app/actions.go b/test/e2e/fixture/app/actions.go index 59376f1069015..51ca1dc4e62d6 100644 --- a/test/e2e/fixture/app/actions.go +++ b/test/e2e/fixture/app/actions.go @@ -318,6 +318,12 @@ func (a *Actions) Delete(cascade bool) *Actions { return a } +func (a *Actions) DeleteWait() *Actions { + a.context.t.Helper() + a.runCli("app", "delete", a.context.name, "--cascade=true", "--wait") + return a +} + func (a *Actions) And(block func()) *Actions { a.context.t.Helper() block()