Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 44 additions & 7 deletions cmd/argocd/commands/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]",
Expand Down Expand Up @@ -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)
Expand All @@ -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
}

Expand Down Expand Up @@ -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]",
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 to the CodeRabbit comment on this, which was resolved without a change. sync and rollback only wait for the operation, so checkResourceStatus passes for every resource and the pending list is always empty. Should we drop the flag from these two commands, or list the resources the operation is still working on?

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.")
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
ctx, cancel := context.WithCancel(ctx)
defer cancel()

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

completed hooks end up in this list. A hook's status is its phase (Succeeded), which never equals Synced, so a PreSync job that finished fine is shown as pending. #26651 skips them with state.Hook != "" && common.OperationPhase(state.Status).Completed(). Can we do the same?

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,
Expand Down Expand Up @@ -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]",
Expand Down Expand Up @@ -2784,14 +2820,15 @@ func NewApplicationRollbackCommand(clientOpts *argocdclient.ClientOptions) *cobr

_, _, err = waitOnApplicationStatus(ctx, acdClient, app.QualifiedName(), timeout, watchOpts{
operation: true,
}, nil, output)
}, nil, output, maxPending)
errors.CheckError(err)
}),
}
command.Flags().BoolVar(&prune, "prune", false, "Allow deleting unexpected resources")
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
}

Expand Down
21 changes: 14 additions & 7 deletions cmd/argocd/commands/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1861,15 +1861,15 @@ 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
},
)
require.NoError(t, err)
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
})

Expand All @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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))
}
11 changes: 6 additions & 5 deletions docs/user-guide/commands/argocd_app_rollback.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions docs/user-guide/commands/argocd_app_sync.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

27 changes: 14 additions & 13 deletions docs/user-guide/commands/argocd_app_wait.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 23 additions & 0 deletions test/e2e/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading