Skip to content

Commit 0dfdb0a

Browse files
fix(cli): Prevent Get & Sync from Hanging on Invalid Application Spec (#21702)
Signed-off-by: almoelda <[email protected]> Co-authored-by: Alexandre Gaudreault <[email protected]>
1 parent 6cf2961 commit 0dfdb0a

File tree

3 files changed

+174
-24
lines changed

3 files changed

+174
-24
lines changed

cmd/argocd/commands/app.go

Lines changed: 104 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"sort"
1313
"strconv"
1414
"strings"
15+
"sync"
1516
"text/tabwriter"
1617
"time"
1718
"unicode/utf8"
@@ -301,8 +302,8 @@ func parentChildDetails(ctx context.Context, appIf application.ApplicationServic
301302
}
302303

303304
func printHeader(ctx context.Context, acdClient argocdclient.Client, app *argoappv1.Application, windows *argoappv1.SyncWindows, showOperation bool, showParams bool, sourcePosition int) {
304-
aURL := appURL(ctx, acdClient, app.Name)
305-
printAppSummaryTable(app, aURL, windows)
305+
appURL := getAppURL(ctx, acdClient, app.Name)
306+
printAppSummaryTable(app, appURL, windows)
306307

307308
if len(app.Status.Conditions) > 0 {
308309
fmt.Println()
@@ -337,6 +338,7 @@ func NewApplicationGetCommand(clientOpts *argocdclient.ClientOptions) *cobra.Com
337338
refresh bool
338339
hardRefresh bool
339340
output string
341+
timeout uint
340342
showParams bool
341343
showOperation bool
342344
appNamespace string
@@ -382,7 +384,8 @@ func NewApplicationGetCommand(clientOpts *argocdclient.ClientOptions) *cobra.Com
382384
`),
383385

384386
Run: func(c *cobra.Command, args []string) {
385-
ctx := c.Context()
387+
ctx, cancel := context.WithCancel(c.Context())
388+
defer cancel()
386389
if len(args) == 0 {
387390
c.HelpFunc()(c, args)
388391
os.Exit(1)
@@ -393,13 +396,54 @@ func NewApplicationGetCommand(clientOpts *argocdclient.ClientOptions) *cobra.Com
393396

394397
appName, appNs := argo.ParseFromQualifiedName(args[0], appNamespace)
395398

396-
app, err := appIf.Get(ctx, &application.ApplicationQuery{
397-
Name: &appName,
398-
Refresh: getRefreshType(refresh, hardRefresh),
399-
AppNamespace: &appNs,
400-
})
399+
if timeout != 0 {
400+
time.AfterFunc(time.Duration(timeout)*time.Second, func() {
401+
if ctx.Err() != nil {
402+
fmt.Println("Timeout function: context already cancelled:", ctx.Err())
403+
} else {
404+
fmt.Println("Timeout function: cancelling context manually")
405+
cancel()
406+
}
407+
})
408+
}
409+
getAppStateWithRetry := func() (*argoappv1.Application, error) {
410+
type getResponse struct {
411+
app *argoappv1.Application
412+
err error
413+
}
414+
415+
ch := make(chan getResponse, 1)
416+
417+
go func() {
418+
app, err := appIf.Get(ctx, &application.ApplicationQuery{
419+
Name: &appName,
420+
Refresh: getRefreshType(refresh, hardRefresh),
421+
AppNamespace: &appNs,
422+
})
423+
ch <- getResponse{app: app, err: err}
424+
}()
425+
426+
select {
427+
case result := <-ch:
428+
return result.app, result.err
429+
case <-ctx.Done():
430+
// Timeout occurred, try again without refresh flag
431+
// Create new context for retry request
432+
ctx := context.Background()
433+
app, err := appIf.Get(ctx, &application.ApplicationQuery{
434+
Name: &appName,
435+
AppNamespace: &appNs,
436+
})
437+
return app, err
438+
}
439+
}
440+
441+
app, err := getAppStateWithRetry()
401442
errors.CheckError(err)
402443

444+
if ctx.Err() != nil {
445+
ctx = context.Background() // Reset context for subsequent requests
446+
}
403447
if sourceName != "" && sourcePosition != -1 {
404448
errors.Fatal(errors.ErrorGeneric, "Only one of source-position and source-name can be specified.")
405449
}
@@ -462,6 +506,7 @@ func NewApplicationGetCommand(clientOpts *argocdclient.ClientOptions) *cobra.Com
462506
},
463507
}
464508
command.Flags().StringVarP(&output, "output", "o", "wide", "Output format. One of: json|yaml|wide|tree")
509+
command.Flags().UintVar(&timeout, "timeout", defaultCheckTimeoutSeconds, "Time out after this many seconds")
465510
command.Flags().BoolVar(&showOperation, "show-operation", false, "Show application operation")
466511
command.Flags().BoolVar(&showParams, "show-params", false, "Show application parameters and overrides")
467512
command.Flags().BoolVar(&refresh, "refresh", false, "Refresh application data when retrieving")
@@ -731,8 +776,8 @@ func appURLDefault(acdClient argocdclient.Client, appName string) string {
731776
return fmt.Sprintf("%s://%s/applications/%s", scheme, server, appName)
732777
}
733778

734-
// appURL returns the URL of an application
735-
func appURL(ctx context.Context, acdClient argocdclient.Client, appName string) string {
779+
// getAppURL returns the URL of an application
780+
func getAppURL(ctx context.Context, acdClient argocdclient.Client, appName string) string {
736781
conn, settingsIf := acdClient.NewSettingsClientOrDie()
737782
defer argoio.Close(conn)
738783
argoSettings, err := settingsIf.Get(ctx, &settings.SettingsQuery{})
@@ -2518,18 +2563,47 @@ func resourceParentChild(ctx context.Context, acdClient argocdclient.Client, app
25182563

25192564
const waitFormatString = "%s\t%5s\t%10s\t%10s\t%20s\t%8s\t%7s\t%10s\t%s\n"
25202565

2566+
// AppWithLock encapsulates the application and its lock
2567+
type AppWithLock struct {
2568+
mu sync.Mutex
2569+
app *argoappv1.Application
2570+
}
2571+
2572+
// NewAppWithLock creates a new AppWithLock instance
2573+
func NewAppWithLock() *AppWithLock {
2574+
return &AppWithLock{}
2575+
}
2576+
2577+
// SetApp safely updates the application
2578+
func (a *AppWithLock) SetApp(app *argoappv1.Application) {
2579+
a.mu.Lock()
2580+
defer a.mu.Unlock()
2581+
a.app = app
2582+
}
2583+
2584+
// GetApp safely retrieves the application
2585+
func (a *AppWithLock) GetApp() *argoappv1.Application {
2586+
a.mu.Lock()
2587+
defer a.mu.Unlock()
2588+
return a.app
2589+
}
2590+
25212591
// waitOnApplicationStatus watches an application and blocks until either the desired watch conditions
25222592
// are fulfilled or we reach the timeout. Returns the app once desired conditions have been filled.
25232593
// Additionally return the operationState at time of fulfilment (which may be different than returned app).
25242594
func waitOnApplicationStatus(ctx context.Context, acdClient argocdclient.Client, appName string, timeout uint, watch watchOpts, selectedResources []*argoappv1.SyncOperationResource, output string) (*argoappv1.Application, *argoappv1.OperationState, error) {
25252595
ctx, cancel := context.WithCancel(ctx)
25262596
defer cancel()
25272597

2598+
appWithLock := NewAppWithLock()
25282599
// refresh controls whether or not we refresh the app before printing the final status.
25292600
// We only want to do this when an operation is in progress, since operations are the only
25302601
// time when the sync status lags behind when an operation completes
25312602
refresh := false
25322603

2604+
// appURL is declared here so that it can be used in the printFinalStatus function when the context is cancelled
2605+
appURL := getAppURL(ctx, acdClient, appName)
2606+
25332607
// printSummary controls whether we print the app summary table, OperationState, and ResourceState
25342608
// We don't want to print these when output type is json or yaml, as the output would become unparsable.
25352609
printSummary := output != "json" && output != "yaml"
@@ -2552,7 +2626,7 @@ func waitOnApplicationStatus(ctx context.Context, acdClient argocdclient.Client,
25522626

25532627
if printSummary {
25542628
fmt.Println()
2555-
printAppSummaryTable(app, appURL(ctx, acdClient, appName), nil)
2629+
printAppSummaryTable(app, appURL, nil)
25562630
fmt.Println()
25572631
if watch.operation {
25582632
printOperationResult(app.Status.OperationState)
@@ -2591,19 +2665,24 @@ func waitOnApplicationStatus(ctx context.Context, acdClient argocdclient.Client,
25912665

25922666
if timeout != 0 {
25932667
time.AfterFunc(time.Duration(timeout)*time.Second, func() {
2594-
_, appClient := acdClient.NewApplicationClientOrDie()
2668+
conn, appClient := acdClient.NewApplicationClientOrDie()
2669+
defer conn.Close()
2670+
// We want to print the final status of the app even if the conditions are not met
2671+
if printSummary {
2672+
fmt.Println()
2673+
fmt.Println("This is the state of the app after wait timed out:")
2674+
}
2675+
// Setting refresh = false because we don't want printFinalStatus to execute a refresh
2676+
refresh = false
2677+
// Updating the app object to the latest state
25952678
app, err := appClient.Get(ctx, &application.ApplicationQuery{
25962679
Name: &appRealName,
25972680
AppNamespace: &appNs,
25982681
})
25992682
errors.CheckError(err)
2600-
2601-
if printSummary {
2602-
fmt.Println()
2603-
fmt.Println("This is the state of the app after `wait` timed out:")
2604-
}
2605-
2606-
printFinalStatus(app)
2683+
// Update the app object
2684+
appWithLock.SetApp(app)
2685+
// Cancel the context to stop the watch
26072686
cancel()
26082687

26092688
if printSummary {
@@ -2626,18 +2705,20 @@ func waitOnApplicationStatus(ctx context.Context, acdClient argocdclient.Client,
26262705
AppNamespace: &appNs,
26272706
})
26282707
errors.CheckError(err)
2708+
appWithLock.SetApp(app) // Update the app object
26292709

26302710
// printFinalStatus() will refresh and update the app object, potentially causing the app's
26312711
// status.operationState to be different than the version when we break out of the event loop.
26322712
// This means the app.status is unreliable for determining the final state of the operation.
26332713
// finalOperationState captures the operationState as it was seen when we met the conditions of
26342714
// the wait, so the caller can rely on it to determine the outcome of the operation.
26352715
// See: https://github.com/argoproj/argo-cd/issues/5592
2636-
finalOperationState := app.Status.OperationState
2716+
finalOperationState := appWithLock.GetApp().Status.OperationState
26372717

2638-
appEventCh := acdClient.WatchApplicationWithRetry(ctx, appName, app.ResourceVersion)
2718+
appEventCh := acdClient.WatchApplicationWithRetry(ctx, appName, appWithLock.GetApp().ResourceVersion)
26392719
for appEvent := range appEventCh {
2640-
app = &appEvent.Application
2720+
appWithLock.SetApp(&appEvent.Application)
2721+
app = appWithLock.GetApp()
26412722

26422723
finalOperationState = app.Status.OperationState
26432724
operationInProgress := false
@@ -2708,7 +2789,7 @@ func waitOnApplicationStatus(ctx context.Context, acdClient argocdclient.Client,
27082789
}
27092790
_ = w.Flush()
27102791
}
2711-
_ = printFinalStatus(app)
2792+
_ = printFinalStatus(appWithLock.GetApp())
27122793
return nil, finalOperationState, fmt.Errorf("timed out (%ds) waiting for app %q match desired state", timeout, appName)
27132794
}
27142795

cmd/argocd/commands/app_test.go

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1947,6 +1947,71 @@ apps Deployment default test Synced Healthy
19471947
assert.Equalf(t, expectationSorted, outputSorted, "Incorrect output %q, should be %q (items order doesn't matter)", output, expectation)
19481948
}
19491949

1950+
func TestWaitOnApplicationStatus_JSON_YAML_WideOutput_With_Timeout(t *testing.T) {
1951+
acdClient := &customAcdClient{&fakeAcdClient{simulateTimeout: 15}}
1952+
ctx := t.Context()
1953+
var selectResource []*v1alpha1.SyncOperationResource
1954+
watch := watchOpts{
1955+
sync: false,
1956+
health: false,
1957+
operation: true,
1958+
suspended: false,
1959+
}
1960+
watch = getWatchOpts(watch)
1961+
1962+
output, _ := captureOutput(func() error {
1963+
_, _, _ = waitOnApplicationStatus(ctx, acdClient, "app-name", 5, watch, selectResource, "")
1964+
return nil
1965+
})
1966+
timeStr := time.Now().Format("2006-01-02T15:04:05-07:00")
1967+
1968+
expectation := `TIMESTAMP GROUP KIND NAMESPACE NAME STATUS HEALTH HOOK MESSAGE
1969+
%s Service default service-name1 Synced Healthy
1970+
%s apps Deployment default test Synced Healthy
1971+
1972+
The command timed out waiting for the conditions to be met.
1973+
1974+
This is the state of the app after wait timed out:
1975+
1976+
Name: argocd/test
1977+
Project: default
1978+
Server: local
1979+
Namespace: argocd
1980+
URL: http://localhost:8080/applications/app-name
1981+
Source:
1982+
- Repo: test
1983+
Target: master
1984+
Path: /test
1985+
Helm Values: path1,path2
1986+
Name Prefix: prefix
1987+
SyncWindow: Sync Allowed
1988+
Sync Policy: Automated (Prune)
1989+
Sync Status: OutOfSync from master
1990+
Health Status: Progressing (health-message)
1991+
1992+
Operation: Sync
1993+
Sync Revision: revision
1994+
Phase:
1995+
Start: 0001-01-01 00:00:00 +0000 UTC
1996+
Finished: 2020-11-10 23:00:00 +0000 UTC
1997+
Duration: 2333448h16m18.871345152s
1998+
Message: test
1999+
2000+
GROUP KIND NAMESPACE NAME STATUS HEALTH HOOK MESSAGE
2001+
Service default service-name1 Synced Healthy
2002+
apps Deployment default test Synced Healthy
2003+
`
2004+
expectation = fmt.Sprintf(expectation, timeStr, timeStr)
2005+
expectationParts := strings.Split(expectation, "\n")
2006+
slices.Sort(expectationParts)
2007+
expectationSorted := strings.Join(expectationParts, "\n")
2008+
outputParts := strings.Split(output, "\n")
2009+
slices.Sort(outputParts)
2010+
outputSorted := strings.Join(outputParts, "\n")
2011+
// Need to compare sorted since map entries may not keep a specific order during serialization, leading to flakiness.
2012+
assert.Equalf(t, expectationSorted, outputSorted, "Incorrect output %q, should be %q (items order doesn't matter)", output, expectation)
2013+
}
2014+
19502015
type customAcdClient struct {
19512016
*fakeAcdClient
19522017
}
@@ -1965,6 +2030,7 @@ func (c *customAcdClient) WatchApplicationWithRetry(ctx context.Context, _ strin
19652030
}
19662031

19672032
go func() {
2033+
time.Sleep(time.Duration(c.simulateTimeout) * time.Second)
19682034
appEventsCh <- &v1alpha1.ApplicationWatchEvent{
19692035
Type: watch.Bookmark,
19702036
Application: newApp,
@@ -2179,7 +2245,9 @@ func (c *fakeAppServiceClient) ListResourceLinks(_ context.Context, _ *applicati
21792245
return nil, nil
21802246
}
21812247

2182-
type fakeAcdClient struct{}
2248+
type fakeAcdClient struct {
2249+
simulateTimeout uint
2250+
}
21832251

21842252
func (c *fakeAcdClient) ClientOptions() argocdclient.ClientOptions {
21852253
return argocdclient.ClientOptions{}

docs/user-guide/commands/argocd_app_get.md

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)