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
8 changes: 4 additions & 4 deletions cmd/app/api_integration.go
Original file line number Diff line number Diff line change
Expand Up @@ -804,7 +804,7 @@ func stripDiffHeader(out string) string {
}

// syncSelectedApplications syncs the currently selected applications
func (m *Model) syncSelectedApplications(prune, force bool) tea.Cmd {
func (m *Model) syncSelectedApplications(prune, force, dryRun bool) tea.Cmd {
if m.state.Server == nil {
return func() tea.Msg {
return model.ApiErrorMsg{Message: "No server configured"}
Expand All @@ -830,7 +830,7 @@ func (m *Model) syncSelectedApplications(prune, force bool) tea.Cmd {
for _, appName := range selectedApps {
ctx, cancel := appcontext.WithAPITimeout(context.Background())
// Multi-app sync doesn't track per-app namespaces; pass nil (uses Argo CD default)
err := apiService.SyncApplication(ctx, server, appName, nil, api.SyncOptions{Prune: prune, Force: force})
err := apiService.SyncApplication(ctx, server, appName, nil, api.SyncOptions{Prune: prune, Force: force, DryRun: dryRun})
cancel()
if err != nil {
// Convert to structured error and return via TUI error handling
Expand Down Expand Up @@ -918,7 +918,7 @@ func (m *Model) deleteApplication(req model.AppDeleteRequestMsg) tea.Cmd {
}

// syncSingleApplication syncs a specific application
func (m *Model) syncSingleApplication(appName string, appNamespace *string, prune, force bool) tea.Cmd {
func (m *Model) syncSingleApplication(appName string, appNamespace *string, prune, force, dryRun bool) tea.Cmd {
if m.state.Server == nil {
return func() tea.Msg {
return model.ApiErrorMsg{Message: "No server configured"}
Expand All @@ -934,7 +934,7 @@ func (m *Model) syncSingleApplication(appName string, appNamespace *string, prun
apiService := services.NewArgoApiService(server)

cblog.With("component", "api").Info("Starting sync", "app", appName)
err := apiService.SyncApplication(ctx, server, appName, appNamespace, api.SyncOptions{Prune: prune, Force: force})
err := apiService.SyncApplication(ctx, server, appName, appNamespace, api.SyncOptions{Prune: prune, Force: force, DryRun: dryRun})
if err != nil {
cblog.With("component", "api").Error("Sync failed", "app", appName, "err", err)
// Convert to structured error and return via TUI error handling
Expand Down
10 changes: 7 additions & 3 deletions cmd/app/input_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -706,7 +706,7 @@ func (m *Model) handleConfirmSyncKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "y":
// Force deletes and recreates live resources, so it gets its own
// confirmation rather than riding along on this one.
if m.state.Modals.ConfirmSyncForce {
if m.state.Modals.ConfirmSyncForce && !m.state.Modals.ConfirmSyncDryRun {
m.state.Modals.ConfirmSyncForcePending = true
return m, nil
}
Expand All @@ -719,6 +719,9 @@ func (m *Model) handleConfirmSyncKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "f":
m.state.Modals.ConfirmSyncForce = !m.state.Modals.ConfirmSyncForce
return m, nil
case "d":
m.state.Modals.ConfirmSyncDryRun = !m.state.Modals.ConfirmSyncDryRun
return m, nil
case "w":
// Toggle watch option (single or multi)
m.state.Modals.ConfirmSyncWatch = !m.state.Modals.ConfirmSyncWatch
Expand Down Expand Up @@ -762,6 +765,7 @@ func (m *Model) startConfirmedSync() (tea.Model, tea.Cmd) {
targetNamespace := m.state.Modals.ConfirmTargetNamespace
prune := m.state.Modals.ConfirmSyncPrune
force := m.state.Modals.ConfirmSyncForce
dryRun := m.state.Modals.ConfirmSyncDryRun
m.state.Modals.ConfirmSyncLoading = true
m.state.Mode = model.ModeConfirmSync

Expand All @@ -770,9 +774,9 @@ func (m *Model) startConfirmedSync() (tea.Model, tea.Cmd) {
"target", *target,
"isMulti", *target == "__MULTI__")
if *target == "__MULTI__" {
return m, m.syncSelectedApplications(prune, force)
return m, m.syncSelectedApplications(prune, force, dryRun)
} else {
return m, m.syncSingleApplication(*target, targetNamespace, prune, force)
return m, m.syncSingleApplication(*target, targetNamespace, prune, force, dryRun)
}
}
return m, nil
Expand Down
53 changes: 53 additions & 0 deletions cmd/app/sync_dry_run_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package main

import (
"strings"
"testing"

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

func TestSyncModal_DKeyTogglesDryRun(t *testing.T) {
m := syncModalModel(t)

m = press(t, m, "d")

if !m.state.Modals.ConfirmSyncDryRun {
t.Error("expected d to turn dry run on")
}
}

func TestSyncModal_DryRunSkipsTheForceConfirmationBecauseNothingIsApplied(t *testing.T) {
m := syncModalModel(t)
m.state.Modals.ConfirmSyncForce = true
m.state.Modals.ConfirmSyncDryRun = true

m = press(t, m, "y")

if m.state.Modals.ConfirmSyncForcePending {
t.Error("expected no force confirmation during a dry run — nothing is applied")
}
if !m.state.Modals.ConfirmSyncLoading {
t.Error("expected the dry run to start immediately")
}
}

func TestSyncModal_DryRunMarksForceInert(t *testing.T) {
m := buildBaseModel(100, 30)
m.state.Mode = model.ModeConfirmSync
target := "demo-app"
m.state.Modals.ConfirmTarget = &target
m.state.Modals.ConfirmSyncForce = true
m.state.Modals.ConfirmSyncDryRun = true

out := stripANSI(m.renderConfirmSyncModal())

force := optionLine(t, out, "Force")
if !strings.Contains(force, "inert in dry run") {
t.Errorf("expected force marked inert during a dry run, got %q", force)
}
dryRun := optionLine(t, out, "Dry run")
if !strings.Contains(dryRun, "validates only") {
t.Errorf("expected the dry run row to say what it does, got %q", dryRun)
}
}
1 change: 1 addition & 0 deletions cmd/app/testdata/snapshots/modal_confirm_sync.golden
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
│ │
│ p Prune Off │
│ f Force Off │
│ d Dry run Off │
│ w Watch On │
│ │
│ Sync Cancel │
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
│ │
│ p Prune On removes extras │
│ f Force Off │
│ d Dry run Off │
│ w Watch On │
│ │
│ Sync Cancel │
Expand Down
10 changes: 9 additions & 1 deletion cmd/app/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -776,9 +776,17 @@ func (m *Model) renderConfirmSyncModal() string {

// Left-aligned so the eye scans one column of values; the title and
// buttons stay centered, because they are not a list.
// A client-side dry run never touches a live resource, so force cannot
// delete or recreate anything: say so rather than leaving it looking armed.
force := syncOption{Key: "f", Label: "Force", On: m.state.Modals.ConfirmSyncForce, Clause: "delete & recreate", Danger: true}
if m.state.Modals.ConfirmSyncDryRun {
force.Clause, force.Danger, force.Inert = "inert in dry run", false, true
}

aux := renderSyncOptions([]syncOption{
{Key: "p", Label: "Prune", On: m.state.Modals.ConfirmSyncPrune, Clause: "removes extras", Danger: true},
{Key: "f", Label: "Force", On: m.state.Modals.ConfirmSyncForce, Clause: "delete & recreate", Danger: true},
force,
{Key: "d", Label: "Dry run", On: m.state.Modals.ConfirmSyncDryRun, Clause: "validates only", Info: true},
{Key: "w", Label: "Watch", On: m.state.Modals.ConfirmSyncWatch},
}, innerWidth)

Expand Down
9 changes: 8 additions & 1 deletion cmd/app/view_modals.go
Original file line number Diff line number Diff line change
Expand Up @@ -742,6 +742,8 @@ type syncOption struct {
On bool
Clause string // dim consequence text, shown beside the value only when on
Danger bool // destructive when on, so On reads in Danger rather than Warning
Info bool // on, but with no effect on the cluster
Inert bool // on, but neutralised by another option
}

// The option rows are a fixed three-column grid. The columns do not adapt to
Expand All @@ -764,8 +766,13 @@ func renderSyncOptions(opts []syncOption, innerWidth int) string {
if o.On {
value = "On"
valueStyle = lipgloss.NewStyle().Foreground(yellowBright).Bold(true)
if o.Danger {
switch {
case o.Inert:
valueStyle = dim
case o.Danger:
valueStyle = lipgloss.NewStyle().Foreground(outOfSyncColor).Bold(true)
case o.Info:
valueStyle = lipgloss.NewStyle().Foreground(cyanBright).Bold(true)
}
}

Expand Down
37 changes: 37 additions & 0 deletions e2e/sync_options_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,3 +156,40 @@ func TestSyncApp_WithoutForce_SendsNoStrategy(t *testing.T) {
t.Errorf("expected no strategy on an ordinary sync, got %v", strategy)
}
}

func TestSyncApp_WithDryRun_SendsDryRun(t *testing.T) {
t.Parallel()
tf, rec := startAppsView(t)

_ = tf.Send("s")
if !tf.WaitForScreen("Dry run", 3*time.Second) {
t.Fatalf("sync modal never showed the dry run option:\n%s", tf.Screen())
}
_ = tf.Send("d")
_ = tf.Send("y")

if dryRun := syncBody(t, rec, 1)[0]["dryRun"]; dryRun != true {
t.Errorf("expected dryRun=true, got %s", rec.Calls[0].Body)
}
}

func TestSyncApp_DryRunWithForce_SkipsTheForceConfirmation(t *testing.T) {
t.Parallel()
tf, rec := startAppsView(t)

_ = tf.Send("s")
if !tf.WaitForScreen("Dry run", 3*time.Second) {
t.Fatalf("sync modal never opened:\n%s", tf.Screen())
}
_ = tf.Send("f")
_ = tf.Send("d")
_ = tf.Send("y") // nothing is applied, so this must sync rather than ask again

body := syncBody(t, rec, 1)[0]
if body["dryRun"] != true {
t.Errorf("expected dryRun=true, got %s", rec.Calls[0].Body)
}
if force, _ := hookForce(t, body); !force {
t.Errorf("expected force still sent on a dry run, got %s", rec.Calls[0].Body)
}
}
21 changes: 21 additions & 0 deletions pkg/api/applications_sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,24 @@ func TestSyncApplication_WithoutForce_SendsNoStrategy(t *testing.T) {
t.Errorf("expected no strategy when force is off, got %v", strategy)
}
}

func TestSyncApplication_DryRun_IsSentSoArgoCDAppliesNothing(t *testing.T) {
var body map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Errorf("decoding sync request body: %v", err)
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("{}"))
}))
defer server.Close()

svc := NewApplicationService(&model.Server{BaseURL: server.URL, Token: "test-token"})
if err := svc.SyncApplication(context.Background(), "test-app", &SyncOptions{DryRun: true}); err != nil {
t.Fatalf("SyncApplication: %v", err)
}

if body["dryRun"] != true {
t.Errorf("expected dryRun=true in the request body, got %v", body)
}
}
1 change: 1 addition & 0 deletions pkg/model/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ type ModalState struct {
ConfirmSyncPrune bool `json:"confirmSyncPrune"`
ConfirmSyncWatch bool `json:"confirmSyncWatch"`
ConfirmSyncForce bool `json:"confirmSyncForce"`
ConfirmSyncDryRun bool `json:"confirmSyncDryRun"`
// ConfirmSyncForcePending shows the force confirmation in place of the
// options: force deletes and recreates live resources, so it is not a
// thing to hand over on a single keypress.
Expand Down
Loading