diff --git a/cmd/argocd/commands/app.go b/cmd/argocd/commands/app.go index 37538d5935b80..a1e74a67d3488 100644 --- a/cmd/argocd/commands/app.go +++ b/cmd/argocd/commands/app.go @@ -1596,6 +1596,7 @@ func NewApplicationWaitCommand(clientOpts *argocdclient.ClientOptions) *cobra.Co resources []string output string appNamespace string + maxPending uint ) command := &cobra.Command{ Use: "wait [APPNAME.. | -l selector]", @@ -1651,7 +1652,7 @@ Note that the --hydrated and --operation flags evaluate the global application s if appNamespace != "" && !strings.Contains(appName, "/") { appName = appNamespace + "/" + appName } - _, _, err := waitOnApplicationStatus(ctx, acdClient, appName, timeout, watch, selectedResources, output) + _, _, err := waitOnApplicationStatus(ctx, acdClient, appName, timeout, watch, selectedResources, output, maxPending) if err != nil { if isContextCanceledErr(err) { log.Fatalf("timed out (%ds) waiting for app %q to match the expected conditions", timeout, appName) @@ -1673,6 +1674,7 @@ Note that the --hydrated and --operation flags evaluate the global application s command.Flags().UintVar(&timeout, "timeout", defaultCheckTimeoutSeconds, "Time out after this many seconds") command.Flags().StringVarP(&appNamespace, "app-namespace", "N", "", "Only wait for an application in namespace") command.Flags().StringVarP(&output, "output", "o", "wide", "Output format. One of: json|yaml|wide|tree|tree=detailed") + command.Flags().UintVar(&maxPending, "max-pending-resources", 10, "Maximum number of pending resources to show in timeout error messages (0 = show all)") return command } @@ -1737,6 +1739,7 @@ func NewApplicationSyncCommand(clientOpts *argocdclient.ClientOptions) *cobra.Co ignoreNormalizerOpts normalizers.IgnoreNormalizerOpts serverSideDiffConcurrency int serverSideDiffMaxBatchKB int + maxPending uint ) command := &cobra.Command{ Use: "sync [APPNAME... | -l selector | --project project-name]", @@ -2045,7 +2048,7 @@ func NewApplicationSyncCommand(clientOpts *argocdclient.ClientOptions) *cobra.Co errors.CheckError(err) if !async { - app, opState, err := waitOnApplicationStatus(ctx, acdClient, appQualifiedName, timeout, watchOpts{operation: true}, selectedResources, output) + app, opState, err := waitOnApplicationStatus(ctx, acdClient, appQualifiedName, timeout, watchOpts{operation: true}, selectedResources, output, maxPending) errors.CheckError(err) if !dryRun { @@ -2081,6 +2084,7 @@ func NewApplicationSyncCommand(clientOpts *argocdclient.ClientOptions) *cobra.Co command.Flags().BoolVar(&serverSideApply, "server-side", false, "Use server-side apply while syncing the application") command.Flags().BoolVar(&applyOutOfSyncOnly, "apply-out-of-sync-only", false, "Sync only out-of-sync resources") command.Flags().BoolVar(&async, "async", false, "Do not wait for application to sync before continuing") + command.Flags().UintVar(&maxPending, "max-pending-resources", 10, "Maximum number of pending resources to show in timeout error messages (0 = show all)") command.Flags().StringVar(&local, "local", "", "Path to a local directory. When this flag is present no git queries will be made") command.Flags().StringVar(&localRepoRoot, "local-repo-root", "/", "Path to the repository root. Used together with --local allows setting the repository root") command.Flags().StringArrayVar(&infos, "info", []string{}, "A list of key-value pairs during sync process. These infos will be persisted in app.") @@ -2352,7 +2356,7 @@ func appHydrationFinished(app *argoappv1.Application) bool { // checkAppWaitConditions evaluates whether an application currently matches the // conditions requested by `argocd app wait`. It returns whether the conditions // are met (ready) and whether a sync/refresh operation is still in progress. -// It does not mutate any state — callers are responsible for any side effects +// It does not mutate any state -- callers are responsible for any side effects // such as triggering a status refresh before printing the final summary. func checkAppWaitConditions(app *argoappv1.Application, watch watchOpts, selectedResources []*argoappv1.SyncOperationResource) (ready, operationInProgress bool) { operationInProgress = isOperationInProgress(app) @@ -2384,10 +2388,21 @@ func checkAppWaitConditions(app *argoappv1.Application, watch watchOpts, selecte return ready, operationInProgress } +// formatPendingResources builds a summary string for resources that have not +// reached the desired state. When maxPending > 0 and the list exceeds that +// value, the output is truncated and a "... and N more" suffix is appended. +// maxPending == 0 means no limit (show all). +func formatPendingResources(pending []string, maxPending uint) string { + if maxPending > 0 && uint(len(pending)) > maxPending { + return strings.Join(pending[:maxPending], ", ") + fmt.Sprintf(", ... and %d more", len(pending)-int(maxPending)) + } + return strings.Join(pending, ", ") +} + // waitOnApplicationStatus watches an application and blocks until either the desired watch conditions // are fulfilled or we reach the timeout. Returns the app once desired conditions have been filled. // Additionally return the operationState at time of fulfilment (which may be different than returned app). -func waitOnApplicationStatus(ctx context.Context, acdClient argocdclient.Client, appName string, timeout uint, watch watchOpts, selectedResources []*argoappv1.SyncOperationResource, output string) (*argoappv1.Application, *argoappv1.OperationState, error) { +func waitOnApplicationStatus(ctx context.Context, acdClient argocdclient.Client, appName string, timeout uint, watch watchOpts, selectedResources []*argoappv1.SyncOperationResource, output string, maxPending uint) (*argoappv1.Application, *argoappv1.OperationState, error) { ctx, cancel := context.WithCancel(ctx) defer cancel() @@ -2515,7 +2530,7 @@ func waitOnApplicationStatus(ctx context.Context, acdClient argocdclient.Client, // If the application already matches the desired wait conditions, return // immediately. Without this, the subsequent watch would block until the // command timeout because the event stream only delivers messages when - // the application CR changes — if nothing needs to change, no events + // the application CR changes -- if nothing needs to change, no events // arrive. Skip the early return for --delete, which needs an actual // Deleted event from the watch. See https://github.com/argoproj/argo-cd/issues/12211. if !watch.delete { @@ -2568,7 +2583,27 @@ func waitOnApplicationStatus(ctx context.Context, acdClient argocdclient.Client, _ = w.Flush() } _ = printFinalStatus(appWithLock.GetApp()) - return nil, finalOperationState, fmt.Errorf("timed out (%ds) waiting for app %q match desired state", timeout, appName) + app = appWithLock.GetApp() + + var pending []string + var resList []*argoappv1.SyncOperationResource + if len(selectedResources) > 0 { + resList = selectedResources + } + for _, state := range getResourceStates(app, resList) { + if watch.delete { + pending = append(pending, state.Key()) + continue + } + if !checkResourceStatus(watch, state.Health, state.Status) { + pending = append(pending, state.Key()) + } + } + + if len(pending) > 0 { + return nil, finalOperationState, fmt.Errorf("timed out (%ds) waiting for app %q to match desired state. Pending resources: %s", timeout, appName, formatPendingResources(pending, maxPending)) + } + return nil, finalOperationState, fmt.Errorf("timed out (%ds) waiting for app %q to match desired state", timeout, appName) } // isContextCanceledErr returns true if the error is a context cancellation or deadline exceeded, @@ -2745,6 +2780,7 @@ func NewApplicationRollbackCommand(clientOpts *argocdclient.ClientOptions) *cobr timeout uint output string appNamespace string + maxPending uint ) command := &cobra.Command{ Use: "rollback APPNAME [ID]", @@ -2784,7 +2820,7 @@ func NewApplicationRollbackCommand(clientOpts *argocdclient.ClientOptions) *cobr _, _, err = waitOnApplicationStatus(ctx, acdClient, app.QualifiedName(), timeout, watchOpts{ operation: true, - }, nil, output) + }, nil, output, maxPending) errors.CheckError(err) }), } @@ -2792,6 +2828,7 @@ func NewApplicationRollbackCommand(clientOpts *argocdclient.ClientOptions) *cobr command.Flags().UintVar(&timeout, "timeout", defaultCheckTimeoutSeconds, "Time out after this many seconds") command.Flags().StringVarP(&output, "output", "o", "wide", "Output format. One of: json|yaml|wide|tree|tree=detailed") command.Flags().StringVarP(&appNamespace, "app-namespace", "N", "", "Rollback application in namespace") + command.Flags().UintVar(&maxPending, "max-pending-resources", 10, "Maximum number of pending resources to show in timeout error messages (0 = show all)") return command } diff --git a/cmd/argocd/commands/app_test.go b/cmd/argocd/commands/app_test.go index 2d179bc6ea972..90f5f35acb097 100644 --- a/cmd/argocd/commands/app_test.go +++ b/cmd/argocd/commands/app_test.go @@ -1861,7 +1861,7 @@ func TestWaitOnApplicationStatus_JSON_YAML_WideOutput(t *testing.T) { output, err := captureOutput( func() error { - _, _, _ = waitOnApplicationStatus(ctx, acdClient, "app-name", 0, watch, selectResource, "json") + _, _, _ = waitOnApplicationStatus(ctx, acdClient, "app-name", 0, watch, selectResource, "json", 0) return nil }, ) @@ -1869,7 +1869,7 @@ func TestWaitOnApplicationStatus_JSON_YAML_WideOutput(t *testing.T) { assert.True(t, json.Valid([]byte(output))) output, err = captureOutput(func() error { - _, _, _ = waitOnApplicationStatus(ctx, acdClient, "app-name", 0, watch, selectResource, "yaml") + _, _, _ = waitOnApplicationStatus(ctx, acdClient, "app-name", 0, watch, selectResource, "yaml", 0) return nil }) @@ -1878,7 +1878,7 @@ func TestWaitOnApplicationStatus_JSON_YAML_WideOutput(t *testing.T) { require.NoError(t, err) output, _ = captureOutput(func() error { - _, _, _ = waitOnApplicationStatus(ctx, acdClient, "app-name", 0, watch, selectResource, "") + _, _, _ = waitOnApplicationStatus(ctx, acdClient, "app-name", 0, watch, selectResource, "", 0) return nil }) timeStr := time.Now().Format("2006-01-02T15:04:05-07:00") @@ -1947,7 +1947,7 @@ func TestWaitOnApplicationStatus_JSON_YAML_WideOutput_With_Timeout(t *testing.T) watch = getWatchOpts(watch) output, _ := captureOutput(func() error { - _, _, _ = waitOnApplicationStatus(ctx, acdClient, "app-name", 5, watch, selectResource, "") + _, _, _ = waitOnApplicationStatus(ctx, acdClient, "app-name", 5, watch, selectResource, "", 0) return nil }) timeStr := time.Now().Format("2006-01-02T15:04:05-07:00") @@ -2206,7 +2206,7 @@ func TestWaitOnApplicationStatus_ReturnsImmediatelyWhenAlreadyInDesiredState(t * } start := time.Now() - _, _, err := waitOnApplicationStatus(ctx, acdClient, "app-name", 0, watch, selectResource, "json") + _, _, err := waitOnApplicationStatus(ctx, acdClient, "app-name", 0, watch, selectResource, "json", 0) elapsed := time.Since(start) require.NoError(t, err) @@ -2222,7 +2222,7 @@ func TestWaitOnApplicationStatus_DeleteWatchSkipsEarlyReturn(t *testing.T) { ctx := t.Context() watch := watchOpts{delete: true} - app, opState, err := waitOnApplicationStatus(ctx, acdClient, "app-name", 0, watch, nil, "") + app, opState, err := waitOnApplicationStatus(ctx, acdClient, "app-name", 0, watch, nil, "", 0) require.NoError(t, err) assert.Nil(t, app) assert.Nil(t, opState) @@ -2237,7 +2237,7 @@ func TestWaitOnApplicationStatus_ReturnsFromWatchLoopWhenEventSatisfiesCondition ctx := t.Context() watch := watchOpts{sync: true, health: true} - app, _, err := waitOnApplicationStatus(ctx, acdClient, "app-name", 0, watch, nil, "json") + app, _, err := waitOnApplicationStatus(ctx, acdClient, "app-name", 0, watch, nil, "json", 0) require.NoError(t, err) // The function returns via the readiness path inside the watch loop. // The returned app may be re-fetched by printFinalStatus so we only @@ -2915,3 +2915,10 @@ func TestIsContextCanceledErr(t *testing.T) { assert.False(t, isContextCanceledErr(errors.New("some other error"))) }) } + +func TestFormatPendingResources(t *testing.T) { + assert.Equal(t, "a, b, c", formatPendingResources([]string{"a", "b", "c"}, 0)) + assert.Equal(t, "a, b, c", formatPendingResources([]string{"a", "b", "c"}, 5)) + assert.Equal(t, "a, ... and 2 more", formatPendingResources([]string{"a", "b", "c"}, 1)) + assert.Empty(t, formatPendingResources([]string{}, 1)) +} diff --git a/docs/user-guide/commands/argocd_app_rollback.md b/docs/user-guide/commands/argocd_app_rollback.md index d85b87e377630..aad3a53bf8bef 100644 --- a/docs/user-guide/commands/argocd_app_rollback.md +++ b/docs/user-guide/commands/argocd_app_rollback.md @@ -11,11 +11,12 @@ argocd app rollback APPNAME [ID] [flags] ### Options ``` - -N, --app-namespace string Rollback application in namespace - -h, --help help for rollback - -o, --output string Output format. One of: json|yaml|wide|tree|tree=detailed (default "wide") - --prune Allow deleting unexpected resources - --timeout uint Time out after this many seconds + -N, --app-namespace string Rollback application in namespace + -h, --help help for rollback + --max-pending-resources uint Maximum number of pending resources to show in timeout error messages (0 = show all) (default 10) + -o, --output string Output format. One of: json|yaml|wide|tree|tree=detailed (default "wide") + --prune Allow deleting unexpected resources + --timeout uint Time out after this many seconds ``` ### Options inherited from parent commands diff --git a/docs/user-guide/commands/argocd_app_sync.md b/docs/user-guide/commands/argocd_app_sync.md index dbed821421718..9ae82bd80af9f 100644 --- a/docs/user-guide/commands/argocd_app_sync.md +++ b/docs/user-guide/commands/argocd_app_sync.md @@ -54,6 +54,7 @@ argocd app sync [APPNAME... | -l selector | --project project-name] [flags] --label stringArray Sync only specific resources with a label. This option may be specified repeatedly. --local string Path to a local directory. When this flag is present no git queries will be made --local-repo-root string Path to the repository root. Used together with --local allows setting the repository root (default "/") + --max-pending-resources uint Maximum number of pending resources to show in timeout error messages (0 = show all) (default 10) -o, --output string Output format. One of: json|yaml|wide|tree|tree=detailed (default "wide") --preview-changes Preview difference against the target and live state before syncing app and wait for user confirmation --project stringArray Sync apps that belong to the specified projects. This option may be specified repeatedly. diff --git a/docs/user-guide/commands/argocd_app_wait.md b/docs/user-guide/commands/argocd_app_wait.md index ac80daf47157d..7835fc66e4c70 100644 --- a/docs/user-guide/commands/argocd_app_wait.md +++ b/docs/user-guide/commands/argocd_app_wait.md @@ -44,19 +44,20 @@ argocd app wait [APPNAME.. | -l selector] [flags] ### Options ``` - -N, --app-namespace string Only wait for an application in namespace - --degraded Wait for degraded - --delete Wait for delete - --health Wait for health - -h, --help help for wait - --hydrated Wait for hydration operations (evaluated globally for the application) - --operation Wait for pending operations (evaluated globally for the application) - -o, --output string Output format. One of: json|yaml|wide|tree|tree=detailed (default "wide") - --resource stringArray Sync only specific resources as GROUP:KIND:NAME or !GROUP:KIND:NAME. Fields may be blank and '*' can be used. This option may be specified repeatedly - -l, --selector string Wait for apps by label. Supports '=', '==', '!=', in, notin, exists & not exists. Matching apps must satisfy all of the specified label constraints. - --suspended Wait for suspended - --sync Wait for sync - --timeout uint Time out after this many seconds + -N, --app-namespace string Only wait for an application in namespace + --degraded Wait for degraded + --delete Wait for delete + --health Wait for health + -h, --help help for wait + --hydrated Wait for hydration operations (evaluated globally for the application) + --max-pending-resources uint Maximum number of pending resources to show in timeout error messages (0 = show all) (default 10) + --operation Wait for pending operations (evaluated globally for the application) + -o, --output string Output format. One of: json|yaml|wide|tree|tree=detailed (default "wide") + --resource stringArray Sync only specific resources as GROUP:KIND:NAME or !GROUP:KIND:NAME. Fields may be blank and '*' can be used. This option may be specified repeatedly + -l, --selector string Wait for apps by label. Supports '=', '==', '!=', in, notin, exists & not exists. Matching apps must satisfy all of the specified label constraints. + --suspended Wait for suspended + --sync Wait for sync + --timeout uint Time out after this many seconds ``` ### Options inherited from parent commands diff --git a/test/e2e/cli_test.go b/test/e2e/cli_test.go index a5b9c7e4410cb..721f2f4475294 100644 --- a/test/e2e/cli_test.go +++ b/test/e2e/cli_test.go @@ -64,6 +64,29 @@ func TestCliAppCommand(t *testing.T) { }) } +// TestCliAppWaitMaxPendingResources verifies that the --max-pending-resources flag +// correctly truncates the timeout output for the wait command. +func TestCliAppWaitMaxPendingResources(t *testing.T) { + ctx := Given(t) + ctx.Path("guestbook"). + When(). + CreateApp(). + Then(). + Expect(SyncStatusIs(SyncStatusCodeOutOfSync)). + And(func(_ *Application) { + // Intentionally force a timeout on a new app that hasn't synced + _, err := RunCli("app", "wait", ctx.AppName(), "--timeout", "1", "--max-pending-resources", "1") + require.Error(t, err) + assert.Contains(t, err.Error(), "timed out (1s) waiting for app") + assert.Contains(t, err.Error(), "more") + + _, err2 := RunCli("app", "wait", ctx.AppName(), "--timeout", "1", "--max-pending-resources", "0") + require.Error(t, err2) + assert.Contains(t, err2.Error(), "timed out (1s) waiting for app") + assert.NotContains(t, err2.Error(), "more") + }) +} + // TestNormalArgoCDCommandsExecuteOverPluginsWithSameName verifies that normal Argo CD CLI commands // take precedence over plugins with the same name when both exist in the path. func TestNormalArgoCDCommandsExecuteOverPluginsWithSameName(t *testing.T) {