Skip to content

Commit be354f7

Browse files
authored
chore: Integrate vexec into engine (#5957)
1 parent a906778 commit be354f7

5 files changed

Lines changed: 114 additions & 17 deletions

File tree

internal/engine/engine.go

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import (
99
"fmt"
1010
"io"
1111
"os"
12-
"os/exec"
1312
"path/filepath"
1413
"runtime"
1514
"strings"
@@ -24,6 +23,7 @@ import (
2423
"github.com/gruntwork-io/terragrunt/internal/experiment"
2524
"github.com/gruntwork-io/terragrunt/internal/github"
2625
"github.com/gruntwork-io/terragrunt/internal/os/signal"
26+
"github.com/gruntwork-io/terragrunt/internal/vexec"
2727

2828
"github.com/hashicorp/go-hclog"
2929

@@ -90,10 +90,13 @@ type engineInstance struct {
9090
execOptions *ExecutionOptions
9191
}
9292

93-
// Run executes the given command with the experimental engine.
93+
// Run executes the given command with the experimental engine. The provided
94+
// vexec.Exec is used to spawn the engine plugin subprocess and must be
95+
// OS-backed.
9496
func Run(
9597
ctx context.Context,
9698
l log.Logger,
99+
e vexec.Exec,
97100
execOptions *ExecutionOptions,
98101
) (*util.CmdOutput, error) {
99102
engineClients, err := engineClientsFromContext(ctx)
@@ -110,7 +113,7 @@ func Run(
110113
return nil, errors.New(err)
111114
}
112115

113-
terragruntEngine, client, createEngineErr := createEngine(ctx, l, execOptions)
116+
terragruntEngine, client, createEngineErr := createEngine(ctx, l, e, execOptions)
114117
if createEngineErr != nil {
115118
return nil, errors.New(createEngineErr)
116119
}
@@ -542,6 +545,7 @@ func logEngineMessage(l log.Logger, logLevel proto.LogLevel, content string) {
542545
func createEngine(
543546
ctx context.Context,
544547
l log.Logger,
548+
e vexec.Exec,
545549
execOptions *ExecutionOptions,
546550
) (*proto.EngineClient, *plugin.Client, error) {
547551
if execOptions.EngineConfig == nil {
@@ -597,23 +601,27 @@ func createEngine(
597601
// We use without cancel here to ensure that the plugin isn't killed when the main context is cancelled,
598602
// like it is in the RunCommandWithOutput function. This ensures that we don't cancel the shutdown
599603
// when the command is cancelled.
600-
cmd := exec.CommandContext(
601-
context.WithoutCancel(ctx),
602-
localEnginePath,
603-
)
604-
cmd.Cancel = func() error {
605-
if cmd.Process == nil {
606-
return nil
604+
cmd := e.Command(context.WithoutCancel(ctx), localEnginePath)
605+
cmd.SetEnv([]string{fmt.Sprintf("%s=%s", engineLogLevelEnv, engineLogLevel)})
606+
cmd.SetCancel(func() error {
607+
sig := signal.SignalFromContext(ctx)
608+
if sig == nil {
609+
sig = os.Kill
607610
}
608611

609-
if sig := signal.SignalFromContext(ctx); sig != nil {
610-
return cmd.Process.Signal(sig)
612+
if err := cmd.Signal(sig); err != nil && !errors.Is(err, vexec.ErrProcessNotStarted) {
613+
return err
611614
}
612615

613-
return cmd.Process.Signal(os.Kill)
616+
return nil
617+
})
618+
619+
// hashicorp/go-plugin's ClientConfig requires a concrete *exec.Cmd.
620+
osCmder, ok := cmd.(vexec.OSCmder)
621+
if !ok {
622+
return nil, nil, errors.Errorf("engine plugin spawn: %w", vexec.ErrNotOSBacked)
614623
}
615-
// pass log level to engine
616-
cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", engineLogLevelEnv, engineLogLevel))
624+
617625
client := plugin.NewClient(&plugin.ClientConfig{
618626
Logger: logger,
619627
HandshakeConfig: plugin.HandshakeConfig{
@@ -624,7 +632,7 @@ func createEngine(
624632
Plugins: map[string]plugin.Plugin{
625633
"plugin": &engine.TerragruntGRPCEngine{},
626634
},
627-
Cmd: cmd,
635+
Cmd: osCmder.OSCmd(),
628636
GRPCDialOptions: []grpc.DialOption{
629637
grpc.WithTransportCredentials(insecure.NewCredentials()),
630638
},

internal/engine/engine_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
package engine_test
22

33
import (
4+
"context"
45
"io"
6+
"os"
7+
"path/filepath"
58
"testing"
69

710
"github.com/gruntwork-io/terragrunt/internal/engine"
11+
"github.com/gruntwork-io/terragrunt/internal/vexec"
812
"github.com/gruntwork-io/terragrunt/internal/writer"
13+
"github.com/gruntwork-io/terragrunt/pkg/log"
914
"github.com/stretchr/testify/assert"
1015
"github.com/stretchr/testify/require"
1116
)
@@ -48,3 +53,34 @@ func TestReadEngineOutput(t *testing.T) {
4853
err := engine.ReadEngineOutput(runOptions, false, outputFn)
4954
assert.NoError(t, err)
5055
}
56+
57+
func TestRun_NonOSBackedExecReturnsSentinel(t *testing.T) {
58+
t.Parallel()
59+
60+
sourceFile := filepath.Join(t.TempDir(), "fake-engine")
61+
require.NoError(t, os.WriteFile(sourceFile, []byte("not-a-real-engine"), 0o600))
62+
63+
ctx := engine.WithEngineValues(context.Background())
64+
65+
memExec := vexec.NewMemExec(func(_ context.Context, _ vexec.Invocation) vexec.Result {
66+
return vexec.Result{}
67+
})
68+
69+
opts := &engine.ExecutionOptions{
70+
Writers: writer.Writers{Writer: io.Discard, ErrWriter: io.Discard},
71+
EngineOptions: &engine.EngineOptions{
72+
SkipChecksumCheck: true,
73+
LogLevel: "warn",
74+
},
75+
EngineConfig: &engine.EngineConfig{
76+
Source: sourceFile,
77+
Version: "v0.0.0",
78+
Type: "test",
79+
},
80+
WorkingDir: t.TempDir(),
81+
}
82+
83+
_, err := engine.Run(ctx, log.New(), memExec, opts)
84+
require.Error(t, err)
85+
assert.ErrorIs(t, err, vexec.ErrNotOSBacked)
86+
}

internal/shell/run_cmd.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/engine"
1313
"github.com/gruntwork-io/terragrunt/internal/experiment"
1414
"github.com/gruntwork-io/terragrunt/internal/os/exec"
15+
"github.com/gruntwork-io/terragrunt/internal/vexec"
1516
"github.com/gruntwork-io/terragrunt/internal/writer"
1617
"github.com/gruntwork-io/terragrunt/pkg/log"
1718

@@ -219,7 +220,7 @@ func RunCommandWithOutput(
219220
if runOpts.EngineConfig != nil && runOpts.Experiments.Evaluate(experiment.IacEngine) && !runOpts.NoEngine() {
220221
l.Debugf("Using engine to run command: %s %s", command, strings.Join(args, " "))
221222

222-
cmdOutput, err := engine.Run(ctx, l, &engine.ExecutionOptions{
223+
cmdOutput, err := engine.Run(ctx, l, vexec.NewOSExec(), &engine.ExecutionOptions{
223224
Writers: writer.Writers{
224225
Writer: writer.NewWrappedWriter(cmdStdout, runOpts.Writers.Writer),
225226
ErrWriter: writer.NewWrappedWriter(cmdStderr, runOpts.Writers.ErrWriter),

internal/vexec/vexec.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ var (
3131
ErrStderrAlreadySet = errors.New("vexec: Stderr already set")
3232
// ErrProcessNotStarted is returned from Signal before Start.
3333
ErrProcessNotStarted = errors.New("vexec: process not started")
34+
// ErrNotOSBacked reports that a Cmd does not satisfy OSCmder.
35+
ErrNotOSBacked = errors.New("vexec: Cmd is not OS-backed")
3436
)
3537

3638
// Exec is the process-execution interface used throughout the codebase.
@@ -83,6 +85,13 @@ type ExitCoder interface {
8385
ExitCode() int
8486
}
8587

88+
// OSCmder exposes the underlying *exec.Cmd of an OS-backed Cmd. It is
89+
// intended as an escape hatch for callers that must pass the concrete type
90+
// to a library that does not accept the Cmd interface.
91+
type OSCmder interface {
92+
OSCmd() *exec.Cmd
93+
}
94+
8695
// ExitCode extracts an exit code from err. It returns 0 if err is nil, or -1
8796
// if err does not carry an exit code.
8897
func ExitCode(err error) int {
@@ -140,6 +149,8 @@ func (c *osCmd) SetDir(dir string) { c.cmd.Dir = dir }
140149

141150
func (c *osCmd) SetCancel(fn func() error) { c.cmd.Cancel = fn }
142151

152+
func (c *osCmd) OSCmd() *exec.Cmd { return c.cmd }
153+
143154
func (c *osCmd) Signal(sig os.Signal) error {
144155
if c.cmd.Process == nil {
145156
return ErrProcessNotStarted

internal/vexec/vexec_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,47 @@ func TestMemExec_HandlerReceivesInvocation(t *testing.T) {
4848
assert.Equal(t, []byte("input"), out)
4949
}
5050

51+
func TestOSCmder_OSBackendSatisfies(t *testing.T) {
52+
t.Parallel()
53+
54+
cmd := vexec.NewOSExec().Command(t.Context(), "some-binary", "arg1")
55+
56+
oc, ok := cmd.(vexec.OSCmder)
57+
require.True(t, ok, "OS-backed Cmd must satisfy OSCmder")
58+
59+
got := oc.OSCmd()
60+
require.NotNil(t, got)
61+
assert.Equal(t, []string{"some-binary", "arg1"}, got.Args)
62+
}
63+
64+
func TestOSCmder_PropagatesSetters(t *testing.T) {
65+
t.Parallel()
66+
67+
cmd := vexec.NewOSExec().Command(t.Context(), "some-binary", "arg1")
68+
69+
stdin := strings.NewReader("input")
70+
cmd.SetStdin(stdin)
71+
cmd.SetEnv([]string{"FOO=bar"})
72+
cmd.SetDir("/work")
73+
74+
got := cmd.(vexec.OSCmder).OSCmd()
75+
assert.Equal(t, []string{"some-binary", "arg1"}, got.Args)
76+
assert.Equal(t, []string{"FOO=bar"}, got.Env)
77+
assert.Equal(t, "/work", got.Dir)
78+
assert.Same(t, stdin, got.Stdin)
79+
}
80+
81+
func TestOSCmder_MemBackendDoesNot(t *testing.T) {
82+
t.Parallel()
83+
84+
e := vexec.NewMemExec(func(_ context.Context, _ vexec.Invocation) vexec.Result {
85+
return vexec.Result{}
86+
})
87+
88+
_, ok := e.Command(t.Context(), "echo").(vexec.OSCmder)
89+
assert.False(t, ok, "MemExec Cmd must not satisfy OSCmder")
90+
}
91+
5192
func TestMemExec_CombinedOutput(t *testing.T) {
5293
t.Parallel()
5394

0 commit comments

Comments
 (0)