Skip to content

Commit 14ef8a5

Browse files
authored
chore: Integrating vexec into Command (#6004)
1 parent ecff758 commit 14ef8a5

29 files changed

Lines changed: 356 additions & 121 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: 91 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@ package exec
33

44
import (
55
"context"
6+
"io"
67
"os"
7-
"os/exec"
88
"path/filepath"
99
"sync/atomic"
1010
"time"
1111

1212
"github.com/gruntwork-io/terragrunt/internal/os/signal"
13+
"github.com/gruntwork-io/terragrunt/internal/vexec"
1314
"github.com/gruntwork-io/terragrunt/pkg/log"
1415
"golang.org/x/text/cases"
1516
"golang.org/x/text/language"
@@ -21,78 +22,125 @@ import (
2122
// gracefully after sending an interrupt signal before escalating to SIGKILL.
2223
const DefaultGracefulShutdownDelay = 30 * time.Second
2324

24-
// Cmd is a command type.
25+
// ErrPTYRequiresOSBackend is returned when a Cmd is started with PTY allocation
26+
// requested but the underlying vexec.Exec is not OS-backed.
27+
var ErrPTYRequiresOSBackend = errors.New("PTY allocation requires an OS-backed vexec.Exec")
28+
29+
// Cmd wraps a vexec.Cmd with signal forwarding and optional PTY support.
30+
// The Cmd may be backed by a real OS process or by an in-memory vexec backend
31+
// (used in tests and fuzzers to prevent fork of external binaries).
2532
type Cmd struct {
26-
logger log.Logger
27-
interruptSignal os.Signal
28-
*exec.Cmd
33+
vc vexec.Cmd
34+
interruptSignal os.Signal
2935
filename string
36+
dir string
3037
forwardSignalDelay time.Duration
3138
usePTY bool
3239
gracefulShutdownRegistered atomic.Bool
3340
}
3441

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 {
42+
// Command returns a `Cmd` configured to execute the named program with
43+
// the given arguments via the provided vexec.Exec. PTY allocation requires
44+
// an OS-backed Exec; non-OS backends are accepted but `WithUsePTY(true)`
45+
// will fail at Start with ErrPTYRequiresOSBackend.
46+
func Command(ctx context.Context, e vexec.Exec, name string, args ...string) *Cmd {
47+
vc := e.Command(ctx, name, args...)
48+
3849
cmd := &Cmd{
39-
Cmd: exec.CommandContext(ctx, name, args...),
40-
logger: log.Default(),
50+
vc: vc,
4151
filename: filepath.Base(name),
4252
interruptSignal: signal.InterruptSignal,
4353
}
4454

45-
cmd.Stdin = os.Stdin
46-
cmd.Stdout = os.Stdout
47-
cmd.Stderr = os.Stderr
55+
cmd.SetStdin(os.Stdin)
56+
cmd.SetStdout(os.Stdout)
57+
cmd.SetStderr(os.Stderr)
4858

49-
cmd.WaitDelay = DefaultGracefulShutdownDelay
59+
vc.SetWaitDelay(DefaultGracefulShutdownDelay)
5060

51-
cmd.Cancel = func() error {
61+
vc.SetCancel(func() error {
5262
if cmd.gracefulShutdownRegistered.Load() {
5363
return nil
5464
}
5565

56-
if cmd.Process == nil {
57-
return nil
66+
sig := signal.SignalFromContext(ctx)
67+
if sig == nil {
68+
sig = cmd.interruptSignal
5869
}
5970

60-
if sig := signal.SignalFromContext(ctx); sig != nil {
61-
return cmd.Process.Signal(sig)
71+
if sig == nil {
72+
sig = os.Kill
6273
}
6374

64-
if cmd.interruptSignal != nil {
65-
return cmd.Process.Signal(cmd.interruptSignal)
75+
if err := vc.Signal(sig); err != nil && !errors.Is(err, vexec.ErrProcessNotStarted) {
76+
return err
6677
}
6778

68-
return cmd.Process.Signal(os.Kill)
69-
}
79+
return nil
80+
})
7081

7182
return cmd
7283
}
7384

85+
// SetStdin sets the command's standard input.
86+
func (cmd *Cmd) SetStdin(r io.Reader) { cmd.vc.SetStdin(r) }
87+
88+
// SetStdout sets the command's standard output.
89+
func (cmd *Cmd) SetStdout(w io.Writer) { cmd.vc.SetStdout(w) }
90+
91+
// SetStderr sets the command's standard error.
92+
func (cmd *Cmd) SetStderr(w io.Writer) { cmd.vc.SetStderr(w) }
93+
94+
// SetEnv sets the command's environment in `KEY=value` form.
95+
func (cmd *Cmd) SetEnv(env []string) { cmd.vc.SetEnv(env) }
96+
97+
// SetDir sets the command's working directory.
98+
func (cmd *Cmd) SetDir(dir string) {
99+
cmd.dir = dir
100+
cmd.vc.SetDir(dir)
101+
}
102+
103+
// Dir returns the working directory previously set via SetDir.
104+
func (cmd *Cmd) Dir() string { return cmd.dir }
105+
74106
// Configure sets options to the `Cmd`.
75107
func (cmd *Cmd) Configure(opts ...Option) {
76108
for _, opt := range opts {
77109
opt(cmd)
78110
}
79111
}
80112

81-
// Start starts the specified command but does not wait for it to complete.
82-
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.
113+
// Start starts the command but does not wait for it to complete. When PTY
114+
// allocation is requested, the underlying backend must be OS-backed.
115+
func (cmd *Cmd) Start(l log.Logger) error {
85116
if cmd.usePTY {
86-
if err := runCommandWithPTY(cmd.logger, cmd.Cmd); err != nil {
87-
return err
117+
osCmder, ok := cmd.vc.(vexec.OSCmder)
118+
if !ok {
119+
return ErrPTYRequiresOSBackend
88120
}
89-
} else if err := cmd.Cmd.Start(); err != nil {
121+
122+
return runCommandWithPTY(l, osCmder.OSCmd())
123+
}
124+
125+
if err := cmd.vc.Start(); err != nil {
90126
return errors.New(err)
91127
}
92128

93129
return nil
94130
}
95131

132+
// Wait waits for the command to exit and returns its error.
133+
func (cmd *Cmd) Wait() error { return cmd.vc.Wait() }
134+
135+
// Run starts the command and waits for it to complete.
136+
func (cmd *Cmd) Run(l log.Logger) error {
137+
if err := cmd.Start(l); err != nil {
138+
return err
139+
}
140+
141+
return cmd.Wait()
142+
}
143+
96144
// RegisterGracefullyShutdown registers a graceful shutdown for the
97145
// command in two ways:
98146
// 1. If the context cancel contains a cause with a signal, this means
@@ -106,7 +154,7 @@ func (cmd *Cmd) Start() error {
106154
// was some failure and we need to terminate all executed commands,
107155
// in this situation we are sure that commands did not receive any
108156
// signal, so we send them an interrupt signal immediately.
109-
func (cmd *Cmd) RegisterGracefullyShutdown(ctx context.Context) func() {
157+
func (cmd *Cmd) RegisterGracefullyShutdown(ctx context.Context, l log.Logger) func() {
110158
cmd.gracefulShutdownRegistered.Store(true)
111159

112160
ctxShutdown, cancelShutdown := context.WithCancel(context.Background())
@@ -116,12 +164,12 @@ func (cmd *Cmd) RegisterGracefullyShutdown(ctx context.Context) func() {
116164
case <-ctxShutdown.Done():
117165
case <-ctx.Done():
118166
if cause := new(signal.ContextCanceledError); errors.As(context.Cause(ctx), &cause) && cause.Signal != nil {
119-
cmd.ForwardSignal(ctxShutdown, cause.Signal)
167+
cmd.ForwardSignal(ctxShutdown, l, cause.Signal)
120168

121169
return
122170
}
123171

124-
cmd.SendSignal(cmd.interruptSignal)
172+
cmd.SendSignal(l, cmd.interruptSignal)
125173
}
126174
}()
127175

@@ -130,7 +178,7 @@ func (cmd *Cmd) RegisterGracefullyShutdown(ctx context.Context) func() {
130178

131179
// ForwardSignal forwards a given `sig` with a delay if cmd.forwardSignalDelay is greater than 0,
132180
// and if the same signal is received again, it is forwarded immediately.
133-
func (cmd *Cmd) ForwardSignal(ctx context.Context, sig os.Signal) {
181+
func (cmd *Cmd) ForwardSignal(ctx context.Context, l log.Logger, sig os.Signal) {
134182
ctxDelay, cancelDelay := context.WithCancel(ctx)
135183
defer cancelDelay()
136184

@@ -139,7 +187,7 @@ func (cmd *Cmd) ForwardSignal(ctx context.Context, sig os.Signal) {
139187
}, sig)
140188

141189
if cmd.forwardSignalDelay > 0 {
142-
cmd.logger.Debugf("%s signal will be forwarded to %s with delay %s",
190+
l.Debugf("%s signal will be forwarded to %s with delay %s",
143191
cases.Title(language.English).String(sig.String()),
144192
cmd.filename,
145193
cmd.forwardSignalDelay,
@@ -153,14 +201,16 @@ func (cmd *Cmd) ForwardSignal(ctx context.Context, sig os.Signal) {
153201
case <-ctxDelay.Done():
154202
}
155203

156-
cmd.SendSignal(sig)
204+
cmd.SendSignal(l, sig)
157205
}
158206

159-
// SendSignal sends the given `sig` to the executed command.
160-
func (cmd *Cmd) SendSignal(sig os.Signal) {
161-
cmd.logger.Debugf("%s signal is forwarded to %s", cases.Title(language.English).String(sig.String()), cmd.filename)
207+
// SendSignal sends the given `sig` to the executed command. Errors are logged
208+
// rather than returned; ErrProcessNotStarted is silently ignored because
209+
// callers may race against process startup.
210+
func (cmd *Cmd) SendSignal(l log.Logger, sig os.Signal) {
211+
l.Debugf("%s signal is forwarded to %s", cases.Title(language.English).String(sig.String()), cmd.filename)
162212

163-
if err := cmd.Process.Signal(sig); err != nil {
164-
cmd.logger.Errorf("Failed to forwarding signal %s to %s: %v", sig, cmd.filename, err)
213+
if err := cmd.vc.Signal(sig); err != nil && !errors.Is(err, vexec.ErrProcessNotStarted) {
214+
l.Errorf("Failed to forwarding signal %s to %s: %v", sig, cmd.filename, err)
165215
}
166216
}

internal/os/exec/cmd_test.go

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

0 commit comments

Comments
 (0)