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
4 changes: 3 additions & 1 deletion cmd/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,13 +85,15 @@ func runCreate(cmd *cobra.Command, args []string) error {
if err != nil {
return err
}
liveStdout := true
resp, err := pluginSDK.InvokePluginRPC(owner, req, plugin.CommandExecOptions{
Context: RootCmd.Context(),
Stdin: cmd.InOrStdin(),
Stderr: cmd.ErrOrStderr(),
LiveStderr: true,
LiveStdout: liveStdout,
})
if strings.TrimSpace(resp.Output) != "" {
if strings.TrimSpace(resp.Output) != "" && !liveStdout {
if _, printErr := fmt.Fprint(cmd.OutOrStdout(), resp.Output); printErr != nil {
return printErr
}
Expand Down
76 changes: 6 additions & 70 deletions cmd/healthcheck.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,6 @@ import (
"strings"
"time"

"charm.land/bubbles/v2/spinner"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/libops/sitectl/pkg/config"
"github.com/libops/sitectl/pkg/healthcheck"
"github.com/libops/sitectl/pkg/plugin"
Expand Down Expand Up @@ -164,87 +161,26 @@ func startingComposeServiceNames(report sitevalidate.Report) []string {
return services
}

type healthcheckProgressUpdateMsg struct {
message string
}

type healthcheckProgressDoneMsg struct{}

type healthcheckSpinnerModel struct {
spin spinner.Model
message string
quitting bool
}

func newHealthcheckSpinnerModel(message string) healthcheckSpinnerModel {
return healthcheckSpinnerModel{
spin: spinner.New(spinner.WithSpinner(spinner.Dot), spinner.WithStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("205")))),
message: strings.TrimSpace(message),
}
}

func (m healthcheckSpinnerModel) Init() tea.Cmd {
return m.spin.Tick
}

func (m healthcheckSpinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case spinner.TickMsg:
var cmd tea.Cmd
m.spin, cmd = m.spin.Update(msg)
return m, cmd
case healthcheckProgressUpdateMsg:
m.message = strings.TrimSpace(msg.message)
return m, nil
case healthcheckProgressDoneMsg:
m.quitting = true
return m, tea.Quit
}
return m, nil
}

func (m healthcheckSpinnerModel) View() tea.View {
if m.quitting {
return tea.NewView("")
}
return tea.NewView(m.spin.View() + " " + m.message + "\n")
}

type healthcheckProgress struct {
program *tea.Program
done chan error
line *plugin.ProgressLine
}

func startHealthcheckProgress(out io.Writer, message string) *healthcheckProgress {
progress := &healthcheckProgress{
program: tea.NewProgram(newHealthcheckSpinnerModel(message), tea.WithInput(nil), tea.WithOutput(out)),
done: make(chan error, 1),
}
go func() {
_, err := progress.program.Run()
progress.done <- err
}()
return progress
return &healthcheckProgress{line: plugin.NewProgressLine(out, message, "")}
}

func (p *healthcheckProgress) Update(message string) {
if p == nil || p.program == nil {
if p == nil || p.line == nil {
return
}
p.program.Send(healthcheckProgressUpdateMsg{message: message})
p.line.Report(message, "")
}

func (p *healthcheckProgress) Stop() {
if p == nil || p.program == nil {
if p == nil || p.line == nil {
return
}
p.program.Send(healthcheckProgressDoneMsg{})
select {
case <-p.done:
case <-time.After(2 * time.Second):
p.program.Kill()
<-p.done
}
p.line.Close()
}

func runHealthcheckOnce(cmd *cobra.Command, ctx *config.Context, contextName string, healthcheckParams plugin.HealthcheckRunParams, pluginArgs []string) ([]sitevalidate.Result, error) {
Expand Down
16 changes: 16 additions & 0 deletions cmd/healthcheck_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cmd

import (
"bytes"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -93,3 +94,18 @@ func TestHealthcheckRetryMessageNamesStartingServices(t *testing.T) {
}
}
}

func TestHealthcheckProgressDoesNotWriteControlCharactersToNonTTY(t *testing.T) {
var stderr bytes.Buffer

progress := startHealthcheckProgress(&stderr, "Waiting for healthcheck retry 1: solr starting; next check in 10s")
progress.Update("Waiting for healthcheck retry 2: solr starting; next check in 10s")
progress.Stop()

got := stderr.String()
for _, control := range []string{"\r", "\x1b"} {
if strings.Contains(got, control) {
t.Fatalf("expected non-terminal progress without terminal control characters, got %q", got)
}
}
}
22 changes: 20 additions & 2 deletions pkg/plugin/progress.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"golang.org/x/term"
)

// ProgressLine renders a transient single-line progress indicator on terminals.
type ProgressLine struct {
out *os.File
frames []string
Expand All @@ -22,6 +23,7 @@ type ProgressLine struct {
once sync.Once
}

// NewProgressLine starts a single-line terminal progress indicator.
func NewProgressLine(w io.Writer, title, detail string) *ProgressLine {
file, ok := w.(*os.File)
if !ok || !term.IsTerminal(int(file.Fd())) {
Expand All @@ -35,10 +37,14 @@ func NewProgressLine(w io.Writer, title, detail string) *ProgressLine {
detail: strings.TrimSpace(detail),
done: make(chan struct{}),
}
progress.mu.Lock()
progress.renderLocked()
progress.mu.Unlock()
go progress.animate(120 * time.Millisecond)
return progress
}

// Report updates the progress line text.
func (p *ProgressLine) Report(title, detail string) {
if p == nil || p.out == nil {
return
Expand All @@ -50,6 +56,7 @@ func (p *ProgressLine) Report(title, detail string) {
p.mu.Unlock()
}

// Close stops the progress indicator and clears its line.
func (p *ProgressLine) Close() {
if p == nil || p.out == nil {
return
Expand Down Expand Up @@ -83,6 +90,17 @@ func (p *ProgressLine) renderLocked() {
}
frame := p.frames[p.index%len(p.frames)]
p.index++
line := strings.TrimSpace(strings.Join([]string{p.title, p.detail}, " - "))
_, _ = fmt.Fprintf(p.out, "\r%s %s", frame, line)
line := strings.Join(nonEmptyProgressParts(p.title, p.detail), " - ")
_, _ = fmt.Fprintf(p.out, "\r\033[2K%s %s", frame, line)
}

func nonEmptyProgressParts(parts ...string) []string {
joined := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
joined = append(joined, part)
}
}
return joined
}
56 changes: 56 additions & 0 deletions pkg/plugin/progress_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package plugin

import (
"bytes"
"io"
"os"
"strings"
"testing"
)

func TestProgressLineReturnsNoopForNonTerminalOutput(t *testing.T) {
var out bytes.Buffer
progress := NewProgressLine(&out, "Waiting", "service starting")

progress.Report("Retrying", "")
progress.Close()

if out.String() != "" {
t.Fatalf("expected non-terminal progress to be silent, got %q", out.String())
}
}

func TestProgressLineClearsLineBeforeEachRender(t *testing.T) {
reader, writer, err := os.Pipe()
if err != nil {
t.Fatalf("Pipe() error = %v", err)
}
defer reader.Close()

progress := &ProgressLine{
out: writer,
frames: []string{"-"},
title: "Waiting for healthcheck retry 1",
detail: "service starting",
}
progress.renderLocked()
progress.title = "OK"
progress.detail = ""
progress.renderLocked()
if err := writer.Close(); err != nil {
t.Fatalf("close pipe writer: %v", err)
}

data, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("ReadAll(pipe) error = %v", err)
}
got := string(data)
want := "\r\x1b[2K- Waiting for healthcheck retry 1 - service starting\r\x1b[2K- OK"
if got != want {
t.Fatalf("rendered progress = %q, want %q", got, want)
}
if strings.Count(got, "\r\x1b[2K") != 2 {
t.Fatalf("expected each render to clear the line, got %q", got)
}
}
19 changes: 14 additions & 5 deletions pkg/plugin/rpc_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -354,15 +354,20 @@ func (s *SDK) rpcCommand(rpcCmd *cobra.Command, method string, command *cobra.Co
}

type rpcCommandIO struct {
stdin io.Reader
stderr io.Writer
stdin io.Reader
stderr io.Writer
liveStdout bool
}

func rpcCommandIOFromCommand(cmd *cobra.Command) rpcCommandIO {
if cmd == nil {
return rpcCommandIO{stdin: os.Stdin, stderr: os.Stderr}
return rpcCommandIO{stdin: os.Stdin, stderr: os.Stderr, liveStdout: rpcLiveStdoutEnabled()}
}
return rpcCommandIO{stdin: cmd.InOrStdin(), stderr: cmd.ErrOrStderr()}
return rpcCommandIO{stdin: cmd.InOrStdin(), stderr: cmd.ErrOrStderr(), liveStdout: rpcLiveStdoutEnabled()}
}

func rpcLiveStdoutEnabled() bool {
return os.Getenv("SITECTL_RPC_LIVE_STDOUT") == "1"
}

func rpcCommandContext(cmd *cobra.Command) context.Context {
Expand Down Expand Up @@ -415,7 +420,11 @@ func executeRPCCommandWithIO(runCtx context.Context, method string, command *cob
streams.stderr = os.Stderr
}
command.SetIn(streams.stdin)
command.SetOut(stdout)
output := io.Writer(stdout)
if streams.liveStdout {
output = io.MultiWriter(stdout, streams.stderr)
}
command.SetOut(output)
// Stderr intentionally bypasses the response envelope. The host captures
// the plugin process stderr separately so progress, prompts, and diagnostics
// can stream while stdout remains reserved for the JSON response.
Expand Down
28 changes: 28 additions & 0 deletions pkg/plugin/rpc_command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1192,6 +1192,34 @@ func TestExecuteRPCCommandRestoresCommandState(t *testing.T) {
}
}

func TestExecuteRPCCommandCanMirrorLiveStdout(t *testing.T) {
t.Parallel()

var mirrored bytes.Buffer
root := &cobra.Command{
Use: "root",
RunE: func(cmd *cobra.Command, args []string) error {
_, err := cmd.OutOrStdout().Write([]byte("create progress\n"))
return err
},
}

output, err := executeRPCCommandWithIO(context.Background(), "create.run", root, nil, rpcCommandIO{
stdin: strings.NewReader("rpc stdin"),
stderr: &mirrored,
liveStdout: true,
})
if err != nil {
t.Fatalf("executeRPCCommandWithIO() error = %v", err)
}
if output != "create progress\n" {
t.Fatalf("captured output = %q, want create progress", output)
}
if mirrored.String() != "create progress\n" {
t.Fatalf("mirrored output = %q, want create progress", mirrored.String())
}
}

func TestDiscoveryMetadataInvocationUsesEnvFastPath(t *testing.T) {
t.Setenv("SITECTL_RPC_METADATA", "1")
oldArgs := os.Args
Expand Down
12 changes: 9 additions & 3 deletions pkg/plugin/sdk.go
Original file line number Diff line number Diff line change
Expand Up @@ -448,7 +448,9 @@ func (s *SDK) ExecInContainerInteractive(ctx context.Context, containerID string
}

// CommandExecOptions controls subprocess execution for plugin RPC calls.
// Stdout is always captured into RPCResponse.Output. Use Stdin only for
// Stdout is always captured into RPCResponse.Output. LiveStdout mirrors command
// stdout to the plugin process stderr while still capturing it, because process
// stdout is reserved for the final RPC response envelope. Use Stdin only for
// interactive RPC methods; when Stdin is nil the host sends the RPC envelope
// over stdin instead of argv. When Stdin is set, the request is encoded into
// argv so request args and params may be visible in process listings; never
Expand All @@ -462,6 +464,7 @@ type CommandExecOptions struct {
Stdin io.Reader
Stderr io.Writer
LiveStderr bool
LiveStdout bool
}

type pluginRPCPathOptions struct {
Expand Down Expand Up @@ -575,6 +578,9 @@ func runPluginRPCPath(pluginName, pluginPath string, req RPCRequest, opts plugin
if req.Method == MethodPluginMetadata {
cmd.Env = append(cmd.Env, "SITECTL_RPC_METADATA=1")
}
if opts.LiveStdout {
cmd.Env = append(cmd.Env, "SITECTL_RPC_LIVE_STDOUT=1")
}
cmd.Env = append(cmd.Env, opts.ExtraEnv...)
if width, ok := terminalColumns(); ok {
cmd.Env = append(cmd.Env, fmt.Sprintf("COLUMNS=%d", width))
Expand All @@ -585,9 +591,9 @@ func runPluginRPCPath(pluginName, pluginPath string, req RPCRequest, opts plugin
var stderr bytes.Buffer
cmd.Stdout = stdout
var stderrSink io.Writer
if opts.Stderr != nil && opts.LiveStderr {
if opts.Stderr != nil && (opts.LiveStderr || opts.LiveStdout) {
stderrSink = io.MultiWriter(opts.Stderr, &stderr)
} else if opts.LiveStderr {
} else if opts.LiveStderr || opts.LiveStdout {
stderrSink = io.MultiWriter(os.Stderr, &stderr)
} else {
stderrSink = &stderr
Expand Down
Loading