diff --git a/ignite/pkg/cmdrunner/cmdrunner.go b/ignite/pkg/cmdrunner/cmdrunner.go index d83946a074..46b05ac3be 100644 --- a/ignite/pkg/cmdrunner/cmdrunner.go +++ b/ignite/pkg/cmdrunner/cmdrunner.go @@ -7,7 +7,9 @@ import ( "os" "os/exec" "strings" + "syscall" + "github.com/creack/pty" "golang.org/x/sync/errgroup" "github.com/ignite/cli/v29/ignite/pkg/cmdrunner/step" @@ -24,6 +26,7 @@ type Runner struct { workdir string runParallel bool debug bool + tty bool } // Option defines option to run commands. @@ -77,6 +80,13 @@ func EnableDebug() Option { } } +// TTY simulates a TTY device. +func TTY() Option { + return func(r *Runner) { + r.tty = true + } +} + // New returns a new command runner. func New(options ...Option) *Runner { runner := &Runner{ @@ -133,7 +143,15 @@ func (r *Runner) Run(ctx context.Context, steps ...*step.Step) error { } return err } - command := r.newCommand(step) + + command, err := r.newCommand(step) + if err != nil { + if runErr := runPostExecs(err); runErr != nil { + return runErr + } + continue + } + startErr := command.Start() if startErr != nil { if err := runPostExecs(startErr); err != nil { @@ -205,11 +223,31 @@ func (e *cmdSignalWithWriter) Write(data []byte) (n int, err error) { return e.w.Write(data) } +type ptyExecutor struct { + *exec.Cmd + ptmx *os.File + tty *os.File +} + +func (e *ptyExecutor) Signal(s os.Signal) { + _ = e.Cmd.Process.Signal(s) +} + +func (e *ptyExecutor) Write(data []byte) (n int, err error) { + return e.ptmx.Write(data) +} + +func (e *ptyExecutor) Wait() error { + defer e.ptmx.Close() + defer e.tty.Close() + return e.Cmd.Wait() +} + // newCommand returns a new command to execute. -func (r *Runner) newCommand(step *step.Step) Executor { +func (r *Runner) newCommand(step *step.Step) (Executor, error) { // Return a dummy executor in case of an empty command if step.Exec.Command == "" { - return &dummyExecutor{} + return &dummyExecutor{}, nil } var ( stdout = step.Stdout @@ -240,22 +278,45 @@ func (r *Runner) newCommand(step *step.Step) Executor { command.Env = append(os.Environ(), step.Env...) command.Env = append(command.Env, Env("PATH", goenv.Path())) - // If a custom stdin is provided it will be as the stdin for the command + // If TTY is requested, create a pseudo-terminal + if r.tty { + ptmx, tty, err := pty.Open() + if err != nil { + return nil, err + } + + // Set up the command to use the PTY + command.Stdout = ptmx + command.Stderr = ptmx + command.Stdin = ptmx + command.SysProcAttr = &syscall.SysProcAttr{ + Setctty: true, + Setsid: true, + } + + // Return a special executor that handles the PTY + return &ptyExecutor{ + Cmd: command, + ptmx: ptmx, + tty: tty, + }, nil + } + + // If custom stdin is provided, it will be as the stdin for the command if stdin != nil { command.Stdin = stdin - return &cmdSignal{command} + return &cmdSignal{command}, nil } // If no custom stdin, the executor can write into the stdin of the program writer, err := command.StdinPipe() if err != nil { - // TODO do not panic - panic(err) + return nil, err } - return &cmdSignalWithWriter{command, writer} + return &cmdSignalWithWriter{command, writer}, nil } -// Env returns a new env var value from key and val. +// Env returns a new env var value from a key and val. func Env(key, val string) string { return fmt.Sprintf("%s=%s", key, val) } diff --git a/integration/exec.go b/integration/exec.go index 02d1fd11f3..6aa4fbc0e9 100644 --- a/integration/exec.go +++ b/integration/exec.go @@ -19,6 +19,8 @@ type execOptions struct { ctx context.Context shouldErr, shouldRetry bool stdout, stderr io.Writer + stdin io.Reader + tty bool } type ExecOption func(*execOptions) @@ -51,6 +53,13 @@ func ExecStderr(w io.Writer) ExecOption { } } +// ExecStdin captures stdin of an execution. +func ExecStdin(r io.Reader) ExecOption { + return func(o *execOptions) { + o.stdin = r + } +} + // ExecRetry retries command until it is successful before context is canceled. func ExecRetry() ExecOption { return func(o *execOptions) { @@ -58,6 +67,13 @@ func ExecRetry() ExecOption { } } +// TTY simulates a TTY device. +func TTY() ExecOption { + return func(o *execOptions) { + o.tty = true + } +} + // Exec executes a command step with options where msg describes the expectation from the test. // unless calling with Must(), Exec() will not exit test runtime on failure. func (e Env) Exec(msg string, steps step.Steps, options ...ExecOption) (ok bool) { @@ -77,6 +93,13 @@ func (e Env) Exec(msg string, steps step.Steps, options ...ExecOption) (ok bool) cmdrunner.DefaultStdout(io.MultiWriter(stdout, opts.stdout)), cmdrunner.DefaultStderr(io.MultiWriter(stderr, opts.stderr)), } + if opts.stdin != nil { + copts = append(copts, cmdrunner.DefaultStdin(opts.stdin)) + } + if opts.tty { + copts = append(copts, cmdrunner.TTY()) + } + if HasTestVerboseFlag() { fmt.Printf("Executing %d step(s) for %q\n", len(steps), msg) copts = append(copts, cmdrunner.EnableDebug()) @@ -89,21 +112,19 @@ func (e Env) Exec(msg string, steps step.Steps, options ...ExecOption) (ok bool) Run(opts.ctx, steps...) if errors.Is(err, context.Canceled) { err = nil - } - if err != nil { + } else if err != nil { fmt.Fprintln(os.Stderr, err) if opts.shouldRetry && opts.ctx.Err() == nil { time.Sleep(time.Second) return e.Exec(msg, steps, options...) } - } - if err != nil { msg = fmt.Sprintf("%s\n\nLogs:\n\n%s\n\nError Logs:\n\n%s\n", msg, stdout.String(), stderr.String()) } + if opts.shouldErr { return assert.Error(e.t, err, msg) }