Skip to content

Commit 53d5d66

Browse files
committed
chore: Integrating vexec into Command
1 parent 47db9c8 commit 53d5d66

29 files changed

Lines changed: 326 additions & 91 deletions

internal/cli/commands/exec/exec.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/gruntwork-io/terragrunt/internal/runner/run"
1212
"github.com/gruntwork-io/terragrunt/internal/runner/runcfg"
1313
"github.com/gruntwork-io/terragrunt/internal/shell"
14+
"github.com/gruntwork-io/terragrunt/internal/vexec"
1415
"github.com/gruntwork-io/terragrunt/pkg/log"
1516
"github.com/gruntwork-io/terragrunt/pkg/options"
1617
)
@@ -82,7 +83,7 @@ func runTargetCommand(
8283

8384
return run.RunActionWithHooks(ctx, l, command, runOpts, cfg, r, func(ctx context.Context) error {
8485
_, err := shell.RunCommandWithOutput(
85-
ctx, l, configbridge.ShellRunOptsFromOpts(opts), dir, false, false, command, cmdArgs...,
86+
ctx, l, vexec.NewOSExec(), configbridge.ShellRunOptsFromOpts(opts), dir, false, false, command, cmdArgs...,
8687
)
8788
if err != nil {
8889
return errors.Errorf("failed to run command in directory %s: %w", dir, err)

internal/cli/commands/run/help.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/gruntwork-io/terragrunt/internal/errors"
1313
"github.com/gruntwork-io/terragrunt/internal/tf"
1414
"github.com/gruntwork-io/terragrunt/internal/util"
15+
"github.com/gruntwork-io/terragrunt/internal/vexec"
1516
"github.com/gruntwork-io/terragrunt/pkg/log"
1617
"github.com/gruntwork-io/terragrunt/pkg/options"
1718
)
@@ -59,7 +60,7 @@ func runTFHelp(ctx context.Context, cliCtx *clihelper.Context, l log.Logger, opt
5960

6061
terraformHelpCmd := []string{tf.FlagNameHelpLong, cliCtx.Command.Name}
6162

62-
out, err := tf.RunCommandWithOutput(ctx, l, configbridge.TFRunOptsFromOpts(opts), terraformHelpCmd...)
63+
out, err := tf.RunCommandWithOutput(ctx, l, vexec.NewOSExec(), configbridge.TFRunOptsFromOpts(opts), terraformHelpCmd...)
6364
if err != nil {
6465
var processError util.ProcessExecutionError
6566
if ok := errors.As(err, &processError); ok {

internal/cli/commands/run/run.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/gruntwork-io/terragrunt/internal/shell"
1919
"github.com/gruntwork-io/terragrunt/internal/tf"
2020
"github.com/gruntwork-io/terragrunt/internal/util"
21+
"github.com/gruntwork-io/terragrunt/internal/vexec"
2122
"github.com/gruntwork-io/terragrunt/pkg/config"
2223
"github.com/gruntwork-io/terragrunt/pkg/log"
2324
"github.com/gruntwork-io/terragrunt/pkg/options"
@@ -158,7 +159,7 @@ func runVersionCommand(ctx context.Context, l log.Logger, opts *options.Terragru
158159
}
159160
}
160161

161-
return tf.RunCommand(ctx, l, configbridge.TFRunOptsFromOpts(opts), opts.TerraformCliArgs.Slice()...)
162+
return tf.RunCommand(ctx, l, vexec.NewOSExec(), configbridge.TFRunOptsFromOpts(opts), opts.TerraformCliArgs.Slice()...)
162163
}
163164

164165
func getTFPathFromConfig(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) (string, error) {

internal/os/exec/cmd.go

Lines changed: 86 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@ package exec
33

44
import (
55
"context"
6+
"io"
67
"os"
78
"os/exec"
89
"path/filepath"
910
"sync/atomic"
1011
"time"
1112

1213
"github.com/gruntwork-io/terragrunt/internal/os/signal"
14+
"github.com/gruntwork-io/terragrunt/internal/vexec"
1315
"github.com/gruntwork-io/terragrunt/pkg/log"
1416
"golang.org/x/text/cases"
1517
"golang.org/x/text/language"
@@ -21,78 +23,131 @@ import (
2123
// gracefully after sending an interrupt signal before escalating to SIGKILL.
2224
const DefaultGracefulShutdownDelay = 30 * time.Second
2325

24-
// Cmd is a command type.
26+
// ErrPTYRequiresOSBackend is returned when a Cmd is started with PTY allocation
27+
// requested but the underlying vexec.Exec is not OS-backed.
28+
var ErrPTYRequiresOSBackend = errors.New("PTY allocation requires an OS-backed vexec.Exec")
29+
30+
// Cmd wraps a vexec.Cmd with logging, signal forwarding, and optional PTY support.
31+
// The Cmd may be backed by a real OS process or by an in-memory vexec backend
32+
// (used in tests and fuzzers to prevent fork of external binaries).
2533
type Cmd struct {
26-
logger log.Logger
27-
interruptSignal os.Signal
28-
*exec.Cmd
34+
vc vexec.Cmd
35+
osCmd *exec.Cmd
36+
logger log.Logger
37+
interruptSignal os.Signal
2938
filename string
39+
dir string
3040
forwardSignalDelay time.Duration
3141
usePTY bool
3242
gracefulShutdownRegistered atomic.Bool
3343
}
3444

35-
// Command returns the `Cmd` struct to execute the named program with
36-
// the given arguments.
37-
func Command(ctx context.Context, name string, args ...string) *Cmd {
45+
// Command returns a `Cmd` configured to execute the named program with
46+
// the given arguments via the provided vexec.Exec. PTY allocation requires
47+
// an OS-backed Exec; non-OS backends are accepted but `WithUsePTY(true)`
48+
// will fail at Start with ErrPTYRequiresOSBackend.
49+
func Command(ctx context.Context, e vexec.Exec, name string, args ...string) *Cmd {
50+
vc := e.Command(ctx, name, args...)
51+
3852
cmd := &Cmd{
39-
Cmd: exec.CommandContext(ctx, name, args...),
53+
vc: vc,
4054
logger: log.Default(),
4155
filename: filepath.Base(name),
4256
interruptSignal: signal.InterruptSignal,
4357
}
4458

45-
cmd.Stdin = os.Stdin
46-
cmd.Stdout = os.Stdout
47-
cmd.Stderr = os.Stderr
59+
if osCmder, ok := vc.(vexec.OSCmder); ok {
60+
cmd.osCmd = osCmder.OSCmd()
61+
}
62+
63+
cmd.SetStdin(os.Stdin)
64+
cmd.SetStdout(os.Stdout)
65+
cmd.SetStderr(os.Stderr)
4866

49-
cmd.WaitDelay = DefaultGracefulShutdownDelay
67+
vc.SetWaitDelay(DefaultGracefulShutdownDelay)
5068

51-
cmd.Cancel = func() error {
69+
vc.SetCancel(func() error {
5270
if cmd.gracefulShutdownRegistered.Load() {
5371
return nil
5472
}
5573

56-
if cmd.Process == nil {
57-
return nil
74+
sig := signal.SignalFromContext(ctx)
75+
if sig == nil {
76+
sig = cmd.interruptSignal
5877
}
5978

60-
if sig := signal.SignalFromContext(ctx); sig != nil {
61-
return cmd.Process.Signal(sig)
79+
if sig == nil {
80+
sig = os.Kill
6281
}
6382

64-
if cmd.interruptSignal != nil {
65-
return cmd.Process.Signal(cmd.interruptSignal)
83+
if err := vc.Signal(sig); err != nil && !errors.Is(err, vexec.ErrProcessNotStarted) {
84+
return err
6685
}
6786

68-
return cmd.Process.Signal(os.Kill)
69-
}
87+
return nil
88+
})
7089

7190
return cmd
7291
}
7392

93+
// SetStdin sets the command's standard input.
94+
func (cmd *Cmd) SetStdin(r io.Reader) { cmd.vc.SetStdin(r) }
95+
96+
// SetStdout sets the command's standard output.
97+
func (cmd *Cmd) SetStdout(w io.Writer) { cmd.vc.SetStdout(w) }
98+
99+
// SetStderr sets the command's standard error.
100+
func (cmd *Cmd) SetStderr(w io.Writer) { cmd.vc.SetStderr(w) }
101+
102+
// SetEnv sets the command's environment in `KEY=value` form.
103+
func (cmd *Cmd) SetEnv(env []string) { cmd.vc.SetEnv(env) }
104+
105+
// SetDir sets the command's working directory.
106+
func (cmd *Cmd) SetDir(dir string) {
107+
cmd.dir = dir
108+
cmd.vc.SetDir(dir)
109+
}
110+
111+
// Dir returns the working directory previously set via SetDir.
112+
func (cmd *Cmd) Dir() string { return cmd.dir }
113+
74114
// Configure sets options to the `Cmd`.
75115
func (cmd *Cmd) Configure(opts ...Option) {
76116
for _, opt := range opts {
77117
opt(cmd)
78118
}
79119
}
80120

81-
// Start starts the specified command but does not wait for it to complete.
121+
// Start starts the command but does not wait for it to complete. When PTY
122+
// allocation is requested, the underlying backend must be OS-backed.
82123
func (cmd *Cmd) Start() error {
83-
// If we need to allocate a ptty for the command, route through the ptty routine.
84-
// Otherwise, directly call the command.
85124
if cmd.usePTY {
86-
if err := runCommandWithPTY(cmd.logger, cmd.Cmd); err != nil {
87-
return err
125+
if cmd.osCmd == nil {
126+
return ErrPTYRequiresOSBackend
88127
}
89-
} else if err := cmd.Cmd.Start(); err != nil {
128+
129+
return runCommandWithPTY(cmd.logger, cmd.osCmd)
130+
}
131+
132+
if err := cmd.vc.Start(); err != nil {
90133
return errors.New(err)
91134
}
92135

93136
return nil
94137
}
95138

139+
// Wait waits for the command to exit and returns its error.
140+
func (cmd *Cmd) Wait() error { return cmd.vc.Wait() }
141+
142+
// Run starts the command and waits for it to complete.
143+
func (cmd *Cmd) Run() error {
144+
if err := cmd.Start(); err != nil {
145+
return err
146+
}
147+
148+
return cmd.Wait()
149+
}
150+
96151
// RegisterGracefullyShutdown registers a graceful shutdown for the
97152
// command in two ways:
98153
// 1. If the context cancel contains a cause with a signal, this means
@@ -156,11 +211,13 @@ func (cmd *Cmd) ForwardSignal(ctx context.Context, sig os.Signal) {
156211
cmd.SendSignal(sig)
157212
}
158213

159-
// SendSignal sends the given `sig` to the executed command.
214+
// SendSignal sends the given `sig` to the executed command. Errors are logged
215+
// rather than returned; ErrProcessNotStarted is silently ignored because
216+
// callers may race against process startup.
160217
func (cmd *Cmd) SendSignal(sig os.Signal) {
161218
cmd.logger.Debugf("%s signal is forwarded to %s", cases.Title(language.English).String(sig.String()), cmd.filename)
162219

163-
if err := cmd.Process.Signal(sig); err != nil {
220+
if err := cmd.vc.Signal(sig); err != nil && !errors.Is(err, vexec.ErrProcessNotStarted) {
164221
cmd.logger.Errorf("Failed to forwarding signal %s to %s: %v", sig, cmd.filename, err)
165222
}
166223
}

internal/os/exec/cmd_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package exec_test
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"testing"
7+
8+
"github.com/gruntwork-io/terragrunt/internal/os/exec"
9+
"github.com/gruntwork-io/terragrunt/internal/vexec"
10+
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
// TestCommandWithMemBackend verifies that the wrapper drives a mem-backed
16+
// vexec.Exec end-to-end without forking a real process.
17+
func TestCommandWithMemBackend(t *testing.T) {
18+
t.Parallel()
19+
20+
var got vexec.Invocation
21+
22+
e := vexec.NewMemExec(func(_ context.Context, inv vexec.Invocation) vexec.Result {
23+
got = inv
24+
25+
return vexec.Result{Stdout: []byte("Plan: 0 to add\n")}
26+
})
27+
28+
stdout := &bytes.Buffer{}
29+
30+
cmd := exec.Command(t.Context(), e, "tofu", "plan")
31+
cmd.SetStdout(stdout)
32+
cmd.SetDir("/work")
33+
cmd.SetEnv([]string{"FOO=bar"})
34+
35+
require.NoError(t, cmd.Run())
36+
37+
assert.Equal(t, "tofu", got.Name)
38+
assert.Equal(t, []string{"plan"}, got.Args)
39+
assert.Equal(t, "/work", got.Dir)
40+
assert.Equal(t, []string{"FOO=bar"}, got.Env)
41+
assert.Equal(t, "Plan: 0 to add\n", stdout.String())
42+
assert.Equal(t, "/work", cmd.Dir())
43+
}
44+
45+
// TestCommandWithMemBackendExitCode verifies that handler-reported exit codes
46+
// are recoverable via vexec.ExitCode.
47+
func TestCommandWithMemBackendExitCode(t *testing.T) {
48+
t.Parallel()
49+
50+
e := vexec.NewMemExec(func(context.Context, vexec.Invocation) vexec.Result {
51+
return vexec.Result{ExitCode: 7}
52+
})
53+
54+
cmd := exec.Command(t.Context(), e, "tofu", "apply")
55+
56+
err := cmd.Run()
57+
require.Error(t, err)
58+
59+
assert.Equal(t, 7, vexec.ExitCode(err))
60+
}
61+
62+
// TestCommandWithMemBackendPTYRejected verifies that requesting a PTY against
63+
// a non-OS backend is refused at Start, rather than silently degrading.
64+
func TestCommandWithMemBackendPTYRejected(t *testing.T) {
65+
t.Parallel()
66+
67+
e := vexec.NewMemExec(func(context.Context, vexec.Invocation) vexec.Result {
68+
return vexec.Result{}
69+
})
70+
71+
cmd := exec.Command(t.Context(), e, "tofu", "apply")
72+
cmd.Configure(exec.WithUsePTY(true))
73+
74+
err := cmd.Run()
75+
assert.ErrorIs(t, err, exec.ErrPTYRequiresOSBackend)
76+
}

internal/os/exec/cmd_unix_test.go

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
//go:build linux || darwin
2-
// +build linux darwin
32

43
package exec_test
54

@@ -13,6 +12,7 @@ import (
1312

1413
"github.com/gruntwork-io/terragrunt/internal/os/exec"
1514
"github.com/gruntwork-io/terragrunt/internal/util"
15+
"github.com/gruntwork-io/terragrunt/internal/vexec"
1616

1717
"github.com/stretchr/testify/assert"
1818
"github.com/stretchr/testify/require"
@@ -26,7 +26,7 @@ func TestExitCodeUnix(t *testing.T) {
2626
t.Parallel()
2727

2828
for index := 0; index <= 255; index++ {
29-
cmd := exec.Command(t.Context(), "testdata/test_exit_code.sh", strconv.Itoa(index))
29+
cmd := exec.Command(t.Context(), vexec.NewOSExec(), "testdata/test_exit_code.sh", strconv.Itoa(index))
3030
err := cmd.Run()
3131

3232
if index == 0 {
@@ -52,7 +52,7 @@ func TestNewSignalsForwarderWaitUnix(t *testing.T) {
5252

5353
expectedWait := 5
5454

55-
cmd := exec.Command(t.Context(), "testdata/test_sigint_wait.sh", strconv.Itoa(expectedWait))
55+
cmd := exec.Command(t.Context(), vexec.NewOSExec(), "testdata/test_sigint_wait.sh", strconv.Itoa(expectedWait))
5656

5757
runChannel := make(chan error)
5858

@@ -64,7 +64,7 @@ func TestNewSignalsForwarderWaitUnix(t *testing.T) {
6464

6565
start := time.Now()
6666

67-
cmd.Process.Signal(os.Interrupt)
67+
cmd.SendSignal(os.Interrupt)
6868

6969
err := <-runChannel
7070
require.Error(t, err)
@@ -83,7 +83,10 @@ func TestNewSignalsForwarderMultipleUnix(t *testing.T) {
8383

8484
expectedInterrupts := 10
8585

86-
cmd := exec.Command(t.Context(), "testdata/test_sigint_multiple.sh", strconv.Itoa(expectedInterrupts))
86+
cmd := exec.Command(
87+
t.Context(), vexec.NewOSExec(),
88+
"testdata/test_sigint_multiple.sh", strconv.Itoa(expectedInterrupts),
89+
)
8790

8891
runChannel := make(chan error)
8992

@@ -106,7 +109,7 @@ func TestNewSignalsForwarderMultipleUnix(t *testing.T) {
106109
case err = <-runChannel:
107110
return interrupts, err
108111
default:
109-
cmd.Process.Signal(os.Interrupt)
112+
cmd.SendSignal(os.Interrupt)
110113

111114
interrupts++
112115
}
@@ -132,7 +135,7 @@ func TestGracefulShutdownOnContextCancelUnix(t *testing.T) {
132135

133136
ctx, cancel := context.WithCancel(context.Background())
134137

135-
cmd := exec.Command(ctx, "testdata/test_graceful_shutdown.sh")
138+
cmd := exec.Command(ctx, vexec.NewOSExec(), "testdata/test_graceful_shutdown.sh")
136139

137140
cmd.Configure(exec.WithGracefulShutdownDelay(5 * time.Second))
138141

0 commit comments

Comments
 (0)