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
11 changes: 11 additions & 0 deletions internal/sync/prompt/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,17 @@ func (plan Plan) RequiresConfirmation() bool {
return false
}

// HasDestructiveActions reports whether applying the plan would remove a local
// resource or archive one in LaunchDarkly.
func (plan Plan) HasDestructiveActions() bool {
for _, resource := range plan.Resources {
if resource.Action == ActionArchiveServer || resource.Action == ActionDeleteLocal {
return true
}
}
return false
}

// BlockingError returns a readable error for conflicts or invalid resources.
func (plan Plan) BlockingError() error {
for _, resource := range plan.Resources {
Expand Down
20 changes: 20 additions & 0 deletions internal/sync/prompt/plan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,26 @@ func TestBuildPlanRejectsParentConfigModeMismatch(t *testing.T) {
require.Contains(t, plan.Resources[0].Error, "does not match config mode")
}

func TestPlanHasDestructiveActions(t *testing.T) {
tests := map[string]struct {
action Action
destructive bool
}{
"create server": {action: ActionCreateServer},
"update server": {action: ActionUpdateServer},
"archive server": {action: ActionArchiveServer, destructive: true},
"update local": {action: ActionUpdateLocal},
"delete local": {action: ActionDeleteLocal, destructive: true},
}

for name, test := range tests {
t.Run(name, func(t *testing.T) {
plan := Plan{Resources: []PlannedResource{{Action: test.action}}}
require.Equal(t, test.destructive, plan.HasDestructiveActions())
})
}
}

func testResourceID() ResourceID {
return ResourceID{Kind: syncdomain.KindVariation, ProjectKey: "production", LookupKey: "support/default"}
}
Expand Down
17 changes: 11 additions & 6 deletions internal/sync/prompt/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,12 +158,7 @@ func (runner Runner) Run(options Options) error {
ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer stop()

syncOptions := options
syncOptions.Add = false
syncOptions.Format = ""
syncOptions.Link = ""
syncOptions.Yes = true
syncOptions.Context = ctx
syncOptions := optionsForWatchSync(ctx, options)
// Watch owns the retry loop. Each callback still runs the exact same
// plan, review, revalidation, and execution pipeline as a normal sync.
return runner.watch(ctx, workspace.root, watchDebounce, func(watcher *sourceWatcher) error {
Expand All @@ -175,6 +170,16 @@ func (runner Runner) Run(options Options) error {
return runner.runWorkspaceSync(options, workspace)
}

// optionsForWatchSync clears one-time actions while preserving explicit user
// choices such as --yes for each sync triggered by the watcher.
func optionsForWatchSync(ctx context.Context, options Options) Options {
options.Add = false
options.Format = ""
options.Link = ""
options.Context = ctx
return options
}

// runWorkspaceSync plans, reviews, revalidates, and executes one workspace sync.
func (runner Runner) runWorkspaceSync(options Options, workspace syncWorkspace) error {
var watched *watchedSources
Expand Down
15 changes: 15 additions & 0 deletions internal/sync/prompt/runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,21 @@ func TestRunnerWatchDoesNotRunInitialSync(t *testing.T) {
assert.True(t, watchCalled)
}

func TestOptionsForWatchSyncPreservesYes(t *testing.T) {
ctx := context.Background()
for _, yes := range []bool{false, true} {
options := optionsForWatchSync(ctx, Options{
Add: true, Format: syncreference.PlainMarkdown, Link: "prompt.md", Yes: yes,
})

assert.False(t, options.Add)
assert.Empty(t, options.Format)
assert.Empty(t, options.Link)
assert.Equal(t, yes, options.Yes)
assert.Equal(t, ctx, options.Context)
}
}

func TestRunnerLinksBeforeWatching(t *testing.T) {
root := initGitRepository(t)
runner := NewRunner(noopResourceClient{})
Expand Down
39 changes: 37 additions & 2 deletions internal/sync/prompt/terminal.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package prompt

import (
"bufio"
"context"
"fmt"
"io"
"strings"
Expand All @@ -23,11 +24,12 @@ func reviewAndConfirmPlan(options Options, plan Plan, interactive bool) (bool, e
}
return false, nil
}
if options.Yes || !plan.RequiresConfirmation() {
autoApply := options.Yes || (options.Watch && !plan.HasDestructiveActions())
if autoApply || !plan.RequiresConfirmation() {
Comment thread
cursor[bot] marked this conversation as resolved.
return true, nil
}

confirmed, err := confirmApply(options.Input, options.ErrorOutput, interactive)
confirmed, err := confirmApplyWithContext(options.Context, options.Input, options.ErrorOutput, interactive)
if err != nil {
return false, err
}
Expand All @@ -39,6 +41,39 @@ func reviewAndConfirmPlan(options Options, plan Plan, interactive bool) (bool, e

type terminalCheck func(io.Reader, io.Writer) bool

type confirmationResult struct {
confirmed bool
err error
}

func confirmApplyWithContext(ctx context.Context, input io.Reader, prompt io.Writer, interactive bool) (bool, error) {
if ctx == nil {
return confirmApply(input, prompt, interactive)
}
if err := ctx.Err(); err != nil {
return false, err
}

// io.Reader has no context-aware read contract. Isolate the blocking read
// so cancellation can return immediately; the buffered channel lets the
// reader finish without waiting for a receiver after the caller exits.
result := make(chan confirmationResult, 1)
go func() {
confirmed, err := confirmApply(input, prompt, interactive)
result <- confirmationResult{confirmed: confirmed, err: err}
}()

select {
case <-ctx.Done():
return false, ctx.Err()
case confirmation := <-result:
if err := ctx.Err(); err != nil {
return false, err
}
return confirmation.confirmed, confirmation.err
}
}

// confirmApply asks an interactive user to approve planned changes.
func confirmApply(input io.Reader, prompt io.Writer, interactive bool) (bool, error) {
if !interactive {
Expand Down
123 changes: 123 additions & 0 deletions internal/sync/prompt/terminal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,30 @@ package prompt

import (
"bytes"
"context"
"io"
"strings"
"sync"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

type notifyingWriter struct {
io.Writer
once sync.Once
written chan struct{}
}

func (writer *notifyingWriter) Write(data []byte) (int, error) {
if bytes.Contains(data, []byte("Sync these changes?")) {
writer.once.Do(func() { close(writer.written) })
}
return writer.Writer.Write(data)
}

func TestConfirmApply(t *testing.T) {
tests := []struct {
name string
Expand Down Expand Up @@ -37,3 +54,109 @@ func TestConfirmApply(t *testing.T) {
})
}
}

func TestReviewAndConfirmPlanStopsWhenWatchContextIsCanceled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
input, inputWriter := io.Pipe()
defer input.Close()
defer inputWriter.Close()

promptStarted := make(chan struct{})
output := &notifyingWriter{Writer: io.Discard, written: promptStarted}
results := make(chan struct {
confirmed bool
err error
}, 1)
go func() {
confirmed, err := reviewAndConfirmPlan(Options{
Watch: true, Context: ctx, Input: input, ErrorOutput: output,
}, Plan{Resources: []PlannedResource{{
ID: testResourceID(), Action: ActionArchiveServer,
}}}, true)
results <- struct {
confirmed bool
err error
}{confirmed: confirmed, err: err}
}()

select {
case <-promptStarted:
case <-time.After(time.Second):
t.Fatal("confirmation prompt was not written")
}
cancel()

select {
case result := <-results:
require.False(t, result.confirmed)
require.ErrorIs(t, result.err, context.Canceled)
case <-time.After(time.Second):
_, err := io.WriteString(inputWriter, "yes\n")
require.NoError(t, err)
result := <-results
t.Fatalf("confirmation remained blocked after cancellation and later returned confirmed=%v, err=%v", result.confirmed, result.err)
}
}

func TestReviewAndConfirmPlanRejectsConfirmationWhenWatchContextIsAlreadyCanceled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()

confirmed, err := reviewAndConfirmPlan(Options{
Watch: true, Context: ctx, Input: strings.NewReader("yes\n"), ErrorOutput: io.Discard,
}, Plan{Resources: []PlannedResource{{
ID: testResourceID(), Action: ActionArchiveServer,
}}}, true)

require.False(t, confirmed)
require.ErrorIs(t, err, context.Canceled)
}

func TestReviewAndConfirmPlanWatchPolicy(t *testing.T) {
tests := []struct {
name string
action Action
input string
interactive bool
yes bool
wantContinue bool
wantError string
wantPrompt bool
}{
{name: "updates apply automatically", action: ActionUpdateServer, wantContinue: true},
{
name: "server archive requires confirmation", action: ActionArchiveServer, input: "yes\n",
interactive: true, wantContinue: true, wantPrompt: true,
},
{
name: "local deletion can be declined", action: ActionDeleteLocal, input: "no\n",
interactive: true, wantPrompt: true,
},
{
name: "non-terminal destructive action is rejected", action: ActionArchiveServer,
wantError: "rerun with --yes",
},
{
name: "explicit yes applies destructive action", action: ActionArchiveServer,
yes: true, wantContinue: true,
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var output bytes.Buffer
plan := Plan{Resources: []PlannedResource{{ID: testResourceID(), Action: test.action}}}
continued, err := reviewAndConfirmPlan(Options{
Watch: true, Yes: test.yes, Input: strings.NewReader(test.input), ErrorOutput: &output,
}, plan, test.interactive)

assert.Equal(t, test.wantContinue, continued)
if test.wantError != "" {
require.ErrorContains(t, err, test.wantError)
} else {
require.NoError(t, err)
}
assert.Equal(t, test.wantPrompt, strings.Contains(output.String(), "Sync these changes?"))
})
}
}
Loading