Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
Context: argo.example.com Argonaut dev
╭──────────────────────────────────────────────╮╭─ Application demo-app ───────────────── ⟳ 10s ─╮
│ Application [demo-app] (Degraded, Synced) ││ │
│ ├── Deployment [demo/web] (Degraded) ││ Operation Sync
│ ├── Deployment [demo/web] (Degraded) ││ Operation Sync (dry run, partial)
│ │ └── Pod [demo/web-6f7d9b-x4k2m] (Degrad… ││ Phase Failed │
│ └── Service [demo/web] (Healthy) ││ Started 2 minutes ago │
│ ││ Duration 6s │
Expand Down
12 changes: 11 additions & 1 deletion cmd/app/view_pane.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,17 @@ func renderSyncStatusBody(details *model.SyncStatusDetails, width int, now time.
}

_, phaseColor := statusGlyph(details.Phase)
field("Operation", "Sync", text)
operation := details.Operation
if operation == "" {
operation = "Sync"
}
// The qualifier is the whole point: Argo CD gives a dry run and a
// resource-scoped sync the same phase as a full one.
if details.OperationNote != "" {
field("Operation", operation+" ("+details.OperationNote+")", text)
} else {
field("Operation", operation, text)
}
terminateHint := ""
if details.Phase == "Running" {
terminateHint = "[t] terminate"
Expand Down
14 changes: 8 additions & 6 deletions cmd/app/view_pane_golden_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,12 +134,14 @@ func TestGolden_EventsPane_AppRow_StatusBlockAboveEvents(t *testing.T) {
m.state.Events = &model.EventsState{
Target: model.EventsTarget{AppName: "demo-app"},
Details: &model.SyncStatusDetails{
Phase: "Failed",
Message: "one or more objects failed to apply",
StartedAt: time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC),
FinishedAt: time.Date(2026, 8, 4, 12, 0, 6, 0, time.UTC),
Revision: "a1b2c3d4e5f6789",
InitiatedBy: "alice",
Operation: "Sync",
OperationNote: "dry run, partial",
Phase: "Failed",
Message: "one or more objects failed to apply",
StartedAt: time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC),
FinishedAt: time.Date(2026, 8, 4, 12, 0, 6, 0, time.UTC),
Revision: "a1b2c3d4e5f6789",
InitiatedBy: "alice",
Resources: []model.SyncResourceResult{
{Kind: "Service", Namespace: "demo", Name: "web", Status: "Synced", Message: "service/web unchanged"},
{Kind: "Deployment", Namespace: "demo", Name: "web", Status: "SyncFailed",
Expand Down
52 changes: 52 additions & 0 deletions cmd/app/view_pane_operation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package main

import (
"strings"
"testing"
"time"

"github.com/darksworm/argonaut/pkg/model"
)

func paneOperationLine(t *testing.T, details *model.SyncStatusDetails) string {
t.Helper()
lines := renderSyncStatusBody(details, 46, time.Now(), "")
for _, line := range lines {
if strings.Contains(stripANSI(line), "Operation") {
return stripANSI(line)
}
}
t.Fatalf("no Operation field rendered in:\n%s", strings.Join(lines, "\n"))
return ""
}

func TestSyncStatusPane_QualifiesADryRunSoSucceededIsNotMisread(t *testing.T) {
line := paneOperationLine(t, &model.SyncStatusDetails{
Operation: "Sync",
OperationNote: "dry run",
Phase: "Succeeded",
})

if !strings.Contains(line, "dry run") {
t.Errorf("expected the operation line to mark the dry run, got %q", line)
}
}

func TestSyncStatusPane_NamesTheOperationItWasGiven(t *testing.T) {
line := paneOperationLine(t, &model.SyncStatusDetails{Operation: "Deleting", Phase: "Running"})

if !strings.Contains(line, "Deleting") {
t.Errorf("expected the operation line to read Deleting, got %q", line)
}
if strings.Contains(line, "Sync") {
t.Errorf("expected no hardcoded Sync on a deletion, got %q", line)
}
}

func TestSyncStatusPane_PlainSyncCarriesNoQualifier(t *testing.T) {
line := paneOperationLine(t, &model.SyncStatusDetails{Operation: "Sync", Phase: "Succeeded"})

if strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "Operation")) != "Sync" {
t.Errorf("expected a bare Sync, got %q", line)
}
}
54 changes: 43 additions & 11 deletions pkg/api/applications.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,10 @@ type ApplicationSource struct {

// ApplicationMetadata holds the application CR's object metadata
type ApplicationMetadata struct {
Name string `json:"name"`
Namespace string `json:"namespace,omitempty"`
OwnerReferences []OwnerReference `json:"ownerReferences,omitempty"`
Name string `json:"name"`
DeletionTimestamp string `json:"deletionTimestamp,omitempty"`
Namespace string `json:"namespace,omitempty"`
OwnerReferences []OwnerReference `json:"ownerReferences,omitempty"`
}

// ApplicationDestination identifies the target cluster and namespace
Expand Down Expand Up @@ -94,6 +95,10 @@ type HealthStatus struct {
// SyncOperation holds the sync request parameters
type SyncOperation struct {
Revision string `json:"revision,omitempty"`
// 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"`
Resources []SyncResourceTarget `json:"resources,omitempty"`
}

// OperationInitiator identifies who or what started an operation
Expand Down Expand Up @@ -661,18 +666,45 @@ func ConvertOperationState(argoApp ArgoApplication) *model.SyncStatusDetails {
})
}
}
operation, note := describeOperation(argoApp)
return &model.SyncStatusDetails{
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,
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,
}
}

// describeOperation names what the last operation actually was. Argo CD
// reports a dry run and a resource-scoped sync with the same phase and the
// same result rows as a full sync, so an unqualified "Sync · Succeeded" next
// to an app that is still OutOfSync reads as the wrong conclusion.
func describeOperation(argoApp ArgoApplication) (operation, note string) {
if argoApp.Metadata.DeletionTimestamp != "" {
return "Deleting", ""
}

sync := argoApp.Status.OperationState.Operation.Sync
if sync == nil {
return "Sync", ""
}

var qualifiers []string
if sync.DryRun {
qualifiers = append(qualifiers, "dry run")
}
if len(sync.Resources) > 0 {
qualifiers = append(qualifiers, "partial")
}
return "Sync", strings.Join(qualifiers, ", ")
}

// HasMultipleSources returns true if the application uses multiple sources
func (app *ArgoApplication) HasMultipleSources() bool {
return len(app.Spec.Sources) > 0
Expand Down
65 changes: 65 additions & 0 deletions pkg/api/applications_operation_label_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package api

import "testing"

func appWithOperation(op Operation) ArgoApplication {
return ArgoApplication{
Metadata: ApplicationMetadata{Name: "demo-app"},
Status: ApplicationStatus{
OperationState: OperationState{Phase: "Succeeded", Operation: op},
},
}
}

func TestConvertOperationState_PlainSync_IsNotQualified(t *testing.T) {
details := ConvertOperationState(appWithOperation(Operation{Sync: &SyncOperation{Revision: "HEAD"}}))

if details.Operation != "Sync" {
t.Errorf("expected operation %q, got %q", "Sync", details.Operation)
}
if details.OperationNote != "" {
t.Errorf("expected no qualifier on a plain sync, got %q", details.OperationNote)
}
}

func TestConvertOperationState_DryRun_SaysSoSoASucceededPhaseIsNotMisread(t *testing.T) {
details := ConvertOperationState(appWithOperation(Operation{Sync: &SyncOperation{DryRun: true}}))

if details.OperationNote != "dry run" {
t.Errorf("expected the operation marked as a dry run, got %q", details.OperationNote)
}
}

func TestConvertOperationState_ResourceSubset_IsMarkedPartial(t *testing.T) {
op := Operation{Sync: &SyncOperation{Resources: []SyncResourceTarget{{Kind: "Deployment", Name: "api"}}}}

details := ConvertOperationState(appWithOperation(op))

if details.OperationNote != "partial" {
t.Errorf("expected a resource-scoped sync marked partial, got %q", details.OperationNote)
}
}

func TestConvertOperationState_DryRunOfASubset_CarriesBothQualifiers(t *testing.T) {
op := Operation{Sync: &SyncOperation{
DryRun: true,
Resources: []SyncResourceTarget{{Kind: "Deployment", Name: "api"}},
}}

details := ConvertOperationState(appWithOperation(op))

if details.OperationNote != "dry run, partial" {
t.Errorf("expected both qualifiers, got %q", details.OperationNote)
}
}

func TestConvertOperationState_AppBeingDeleted_ReportsDeletionNotTheOldSync(t *testing.T) {
app := appWithOperation(Operation{Sync: &SyncOperation{Revision: "HEAD"}})
app.Metadata.DeletionTimestamp = "2026-08-18T10:00:00Z"

details := ConvertOperationState(app)

if details.Operation != "Deleting" {
t.Errorf("expected a deleting app to report Deleting, got %q", details.Operation)
}
}
1 change: 1 addition & 0 deletions pkg/api/applications_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ func TestConvertOperationState_FailedSync_CarriesResourceResults(t *testing.T) {
details := ConvertOperationState(argoApp)

want := &model.SyncStatusDetails{
Operation: "Sync",
Phase: "Failed",
Message: "one or more objects failed to apply",
StartedAt: time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC),
Expand Down
18 changes: 10 additions & 8 deletions pkg/model/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,16 @@ 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"`
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"`
Resources []SyncResourceResult `json:"resources"`
}

// App represents an ArgoCD application
Expand Down
Loading