diff --git a/cmd/app/view_pane.go b/cmd/app/view_pane.go index 691983c2..716d53c8 100644 --- a/cmd/app/view_pane.go +++ b/cmd/app/view_pane.go @@ -210,6 +210,11 @@ func renderSyncStatusBody(details *model.SyncStatusDetails, width int, now time. terminateHint = "[t] terminate" } field("Phase", details.Phase, lipgloss.NewStyle().Foreground(phaseColor), terminateHint) + // Argo CD parks the operation in Running with no message when a resource + // carries Prune=confirm, so the reason has to come from the resource list. + if details.AwaitingPruneConfirmation { + field("Waiting on", "prune confirmation", lipgloss.NewStyle().Foreground(currentPalette.Warning)) + } field("Started", humantime.AgoLong(details.StartedAt, now), text) duration := details.FinishedAt.Sub(details.StartedAt) if details.FinishedAt.IsZero() { diff --git a/cmd/app/view_pane_operation_test.go b/cmd/app/view_pane_operation_test.go index 8b579ae1..433f450a 100644 --- a/cmd/app/view_pane_operation_test.go +++ b/cmd/app/view_pane_operation_test.go @@ -50,3 +50,27 @@ func TestSyncStatusPane_PlainSyncCarriesNoQualifier(t *testing.T) { t.Errorf("expected a bare Sync, got %q", line) } } + +func TestSyncStatusPane_ExplainsASyncHeldOnPruneConfirmation(t *testing.T) { + lines := renderSyncStatusBody(&model.SyncStatusDetails{ + Operation: "Sync", + Phase: "Running", + AwaitingPruneConfirmation: true, + }, 46, time.Now(), "") + + out := stripANSI(strings.Join(lines, "\n")) + if !strings.Contains(out, "confirmation") { + t.Errorf("expected the pane to say the sync is waiting for confirmation, got:\n%s", out) + } +} + +func TestSyncStatusPane_SaysNothingAboutConfirmationOnAnOrdinarySync(t *testing.T) { + lines := renderSyncStatusBody(&model.SyncStatusDetails{ + Operation: "Sync", + Phase: "Running", + }, 46, time.Now(), "") + + if strings.Contains(stripANSI(strings.Join(lines, "\n")), "confirmation") { + t.Error("expected no confirmation notice on an ordinary running sync") + } +} diff --git a/pkg/api/applications.go b/pkg/api/applications.go index e4cd2f26..3acd79a8 100644 --- a/pkg/api/applications.go +++ b/pkg/api/applications.go @@ -98,6 +98,7 @@ type SyncOperation struct { // DryRun and Resources qualify what the operation actually did: a dry run // applies nothing, and a non-empty Resources means only a subset was synced. DryRun bool `json:"dryRun,omitempty"` + Prune bool `json:"prune,omitempty"` Resources []SyncResourceTarget `json:"resources,omitempty"` } @@ -668,17 +669,37 @@ func ConvertOperationState(argoApp ArgoApplication) *model.SyncStatusDetails { } operation, note := describeOperation(argoApp) return &model.SyncStatusDetails{ - Operation: operation, - OperationNote: note, - Phase: opState.Phase, - Message: shortenShas(flattenWhitespace(opState.Message)), - StartedAt: opState.StartedAt, - FinishedAt: opState.FinishedAt, - Revision: opState.resolvedRevision(), - InitiatedBy: opState.Operation.InitiatedBy.Username, - Automated: opState.Operation.InitiatedBy.Automated, - Resources: resources, + AwaitingPruneConfirmation: awaitingPruneConfirmation(argoApp), + Operation: operation, + OperationNote: note, + Phase: opState.Phase, + Message: shortenShas(flattenWhitespace(opState.Message)), + StartedAt: opState.StartedAt, + FinishedAt: opState.FinishedAt, + Revision: opState.resolvedRevision(), + InitiatedBy: opState.Operation.InitiatedBy.Username, + Automated: opState.Operation.InitiatedBy.Automated, + Resources: resources, + } +} + +// awaitingPruneConfirmation reports a sync that has stopped and is waiting on +// a human. Argo CD leaves such an operation in Running indefinitely with no +// message, so without this the only visible state is a sync that never ends. +func awaitingPruneConfirmation(argoApp ArgoApplication) bool { + if argoApp.Status.OperationState.Phase != "Running" { + return false + } + sync := argoApp.Status.OperationState.Operation.Sync + if sync == nil || !sync.Prune { + return false + } + for _, r := range argoApp.Status.Resources { + if r.RequiresPruning && r.RequiresDeletionConfirmation { + return true + } } + return false } // describeOperation names what the last operation actually was. Argo CD @@ -781,13 +802,17 @@ type ResourceTree struct { // ResourceStatus holds sync/health status for a managed resource (from Application.status.resources[]) type ResourceStatus struct { - Group string `json:"group"` - Kind string `json:"kind"` - Name string `json:"name"` - Namespace string `json:"namespace,omitempty"` - Status string `json:"status"` // Sync status: "Synced", "OutOfSync" - Version string `json:"version"` - Health *ResourceHealth `json:"health,omitempty"` + Group string `json:"group"` + Kind string `json:"kind"` + Name string `json:"name"` + Namespace string `json:"namespace,omitempty"` + Status string `json:"status"` // Sync status: "Synced", "OutOfSync" + Version string `json:"version"` + Health *ResourceHealth `json:"health,omitempty"` + RequiresPruning bool `json:"requiresPruning,omitempty"` + // RequiresDeletionConfirmation is set by Argo CD when the resource carries + // Prune=confirm or Delete=confirm. + RequiresDeletionConfirmation bool `json:"requiresDeletionConfirmation,omitempty"` } // GetResourceTree retrieves the resource tree for an application diff --git a/pkg/api/applications_prune_confirmation_test.go b/pkg/api/applications_prune_confirmation_test.go new file mode 100644 index 00000000..2b117de7 --- /dev/null +++ b/pkg/api/applications_prune_confirmation_test.go @@ -0,0 +1,82 @@ +package api + +import "testing" + +func runningSyncOf(resources []ResourceStatus) ArgoApplication { + return ArgoApplication{ + Metadata: ApplicationMetadata{Name: "demo-app"}, + Status: ApplicationStatus{ + Resources: resources, + OperationState: OperationState{ + Phase: "Running", + Operation: Operation{Sync: &SyncOperation{Prune: true}}, + }, + }, + } +} + +func TestConvertOperationState_SyncWaitingOnPruneConfirmation_SaysWhyItIsStuck(t *testing.T) { + app := runningSyncOf([]ResourceStatus{ + {Kind: "ConfigMap", Name: "settings"}, + {Kind: "Namespace", Name: "legacy", RequiresPruning: true, RequiresDeletionConfirmation: true}, + }) + + details := ConvertOperationState(app) + + if !details.AwaitingPruneConfirmation { + t.Error("expected a sync held on prune confirmation to be reported as such") + } +} + +func TestConvertOperationState_RunningSyncWithNothingToConfirm_IsJustRunning(t *testing.T) { + app := runningSyncOf([]ResourceStatus{{Kind: "ConfigMap", Name: "settings"}}) + + details := ConvertOperationState(app) + + if details.AwaitingPruneConfirmation { + t.Error("expected no confirmation notice when no resource requires one") + } +} + +func TestConvertOperationState_NonSyncOperationIsNotWaitingOnPruneConfirmation(t *testing.T) { + app := runningSyncOf([]ResourceStatus{{Kind: "Namespace", Name: "legacy", RequiresPruning: true, RequiresDeletionConfirmation: true}}) + app.Status.OperationState.Operation.Sync = nil + + details := ConvertOperationState(app) + + if details.AwaitingPruneConfirmation { + t.Error("expected a non-sync operation not to report waiting on prune confirmation") + } +} + +func TestConvertOperationState_SyncWithoutPruneIsNotWaitingOnPruneConfirmation(t *testing.T) { + app := runningSyncOf([]ResourceStatus{{Kind: "Namespace", Name: "legacy", RequiresPruning: true, RequiresDeletionConfirmation: true}}) + app.Status.OperationState.Operation.Sync.Prune = false + + details := ConvertOperationState(app) + + if details.AwaitingPruneConfirmation { + t.Error("expected a sync with pruning disabled not to report waiting on prune confirmation") + } +} + +func TestConvertOperationState_DeletionConfirmationWithoutPruningIsNotPruneConfirmation(t *testing.T) { + app := runningSyncOf([]ResourceStatus{{Kind: "Namespace", Name: "legacy", RequiresDeletionConfirmation: true}}) + + details := ConvertOperationState(app) + + if details.AwaitingPruneConfirmation { + t.Error("expected deletion confirmation without pruning not to report waiting on prune confirmation") + } +} + +func TestConvertOperationState_FinishedSync_IsNotWaitingOnAnything(t *testing.T) { + app := runningSyncOf([]ResourceStatus{{Kind: "Namespace", Name: "legacy", RequiresPruning: true, RequiresDeletionConfirmation: true}}) + app.Status.OperationState.Phase = "Succeeded" + + details := ConvertOperationState(app) + + if details.AwaitingPruneConfirmation { + t.Error("expected a finished sync not to report waiting on confirmation") + } +} diff --git a/pkg/model/types.go b/pkg/model/types.go index c334b069..8476a1f1 100644 --- a/pkg/model/types.go +++ b/pkg/model/types.go @@ -74,16 +74,19 @@ type SyncResourceResult struct { // SyncStatusDetails is the full last-operation state shown in the // sync-status pane, including per-resource results. type SyncStatusDetails struct { - Phase string `json:"phase"` - Message string `json:"message"` - StartedAt time.Time `json:"startedAt"` - FinishedAt time.Time `json:"finishedAt"` // zero while the operation is running - Revision string `json:"revision"` - InitiatedBy string `json:"initiatedBy"` - Automated bool `json:"automated"` - Operation string `json:"operation"` - OperationNote string `json:"operationNote,omitempty"` - Resources []SyncResourceResult `json:"resources"` + Phase string `json:"phase"` + Message string `json:"message"` + StartedAt time.Time `json:"startedAt"` + FinishedAt time.Time `json:"finishedAt"` // zero while the operation is running + Revision string `json:"revision"` + InitiatedBy string `json:"initiatedBy"` + Automated bool `json:"automated"` + Operation string `json:"operation"` + OperationNote string `json:"operationNote,omitempty"` + // AwaitingPruneConfirmation explains an operation that would otherwise sit + // in Running forever with no visible reason. + AwaitingPruneConfirmation bool `json:"awaitingPruneConfirmation,omitempty"` + Resources []SyncResourceResult `json:"resources"` } // App represents an ArgoCD application