diff --git a/.changes/unreleased/+app-command-piped-stdin.yaml b/.changes/unreleased/+app-command-piped-stdin.yaml new file mode 100644 index 00000000..2dc080cd --- /dev/null +++ b/.changes/unreleased/+app-command-piped-stdin.yaml @@ -0,0 +1,2 @@ +kind: Fixed +body: Preserve piped and redirected stdin for app commands, including when output is captured, without allocating a nonterminal TTY. diff --git a/internal/dockerdeploy/config.go b/internal/dockerdeploy/config.go index 4359fae1..d50cc12e 100644 --- a/internal/dockerdeploy/config.go +++ b/internal/dockerdeploy/config.go @@ -117,6 +117,7 @@ func AppCommand(options AppCommandOptions) error { if terminalOutput == nil { terminalOutput = os.Stdout } + stdin, _, tty := shellCommandIO(os.Stdin, terminalOutput) stateSchema, err := runtimeStateSchema(options.Dir) if err != nil { return err @@ -136,8 +137,8 @@ func AppCommand(options AppCommandOptions) error { return runCurrentAppCommand(ctx, CurrentAppCommandRunInputV1{ DeploymentDir: options.Dir, Arguments: append([]string(nil), options.CommandArgs...), DeployedOnly: options.DeployedOnly, OutputDir: options.OutputDir, OutputFile: options.OutputFile, - Wait: options.Wait, Runtime: runtime, TTY: writerLooksTerminal(terminalOutput), - RunOptions: RunOptions{Stdin: appCommandStdin(terminalOutput), Stdout: options.Stdout, Stderr: options.Stderr, DockerPreflightTimeout: options.DockerPreflightTimeout}, + Wait: options.Wait, Runtime: runtime, TTY: tty, + RunOptions: RunOptions{Stdin: stdin, Stdout: options.Stdout, Stderr: options.Stderr, DockerPreflightTimeout: options.DockerPreflightTimeout}, }) } @@ -158,13 +159,6 @@ func validateCurrentAppCommandRequestV1(dir string, arguments []string, deployed return err } -func appCommandStdin(output io.Writer) io.Reader { - if !writerLooksTerminal(output) || !readerLooksTerminal(os.Stdin) { - return nil - } - return os.Stdin -} - func appCommandError(err error) error { message := err.Error() if trimmed, ok := strings.CutPrefix(message, "docker failed: "); ok { diff --git a/internal/dockerdeploy/config_public_test.go b/internal/dockerdeploy/config_public_test.go index 881f52ad..631f9ffc 100644 --- a/internal/dockerdeploy/config_public_test.go +++ b/internal/dockerdeploy/config_public_test.go @@ -1,7 +1,10 @@ package dockerdeploy import ( + "bytes" "context" + "errors" + "io" "os" "path/filepath" "reflect" @@ -11,6 +14,108 @@ import ( "github.com/omry/reploy/internal/deploy" ) +func TestAppCommandForwardsNonterminalStdin(t *testing.T) { + for _, name := range []string{"pipe", "redirected input", "redirected output", "empty pipe", "closed input", "execution error"} { + t.Run(name, func(t *testing.T) { + payload := []byte("scripted input\n") + var input *os.File + if name == "redirected input" { + var err error + input, err = os.CreateTemp(t.TempDir(), "stdin") + if err != nil { + t.Fatal(err) + } + if _, err := input.Write(payload); err != nil { + t.Fatal(err) + } + if _, err := input.Seek(0, io.SeekStart); err != nil { + t.Fatal(err) + } + } else { + var err error + var writer *os.File + input, writer, err = os.Pipe() + if err != nil { + t.Fatal(err) + } + if name == "empty pipe" || name == "closed input" { + payload = nil + } + if _, err := writer.Write(payload); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + if name == "closed input" { + if err := input.Close(); err != nil { + t.Fatal(err) + } + } + } + var captured, stderr bytes.Buffer + var stdout io.Writer = &captured + if name == "redirected output" { + file, err := os.CreateTemp(t.TempDir(), "stdout") + if err != nil { + t.Fatal(err) + } + defer file.Close() + stdout = file + } + originalInput, originalCommand := os.Stdin, runCurrentAppCommand + t.Cleanup(func() { + os.Stdin, runCurrentAppCommand = originalInput, originalCommand + _ = input.Close() + }) + os.Stdin = input + called := false + executionErr := errors.New("execution failed") + runCurrentAppCommand = func(ctx context.Context, command CurrentAppCommandRunInputV1) error { + called = true + if ctx == nil || command.RunOptions.Stdin != input || command.TTY { + t.Fatal("public app command dropped stdin or requested a nonterminal TTY") + } + if command.RunOptions.Stdout != stdout || command.RunOptions.Stderr != &stderr { + t.Fatal("public app command replaced output streams") + } + got, err := io.ReadAll(command.RunOptions.Stdin) + if name == "closed input" { + if !errors.Is(err, os.ErrClosed) { + t.Fatalf("closed reader error = %v", err) + } + return err + } + if err != nil || !bytes.Equal(got, payload) { + t.Fatalf("stdin did not arrive unchanged through EOF: %v", err) + } + if name == "execution error" { + return executionErr + } + return nil + } + err := AppCommand(AppCommandOptions{Dir: appCommandStateDir(t), CommandArgs: []string{"serve"}, Stdout: stdout, Stderr: &stderr}) + if !called { + t.Fatal("public app command did not reach execution") + } + switch name { + case "closed input": + if !errors.Is(err, os.ErrClosed) { + t.Fatalf("closed-input error was lost: %v", err) + } + case "execution error": + if err != executionErr { + t.Fatalf("execution error was replaced: %v", err) + } + default: + if err != nil { + t.Fatal(err) + } + } + }) + } +} + func TestAppCommandUsesCurrentGenerationWithoutProviderBuild(t *testing.T) { dir := appCommandStateDir(t) originalCommand := runCurrentAppCommand