| // Copyright IBM Corp. 2020, 2026 | |
| // SPDX-License-Identifier: MPL-2.0 | |
| package tfexec | |
| import ( | |
| "bufio" | |
| "bytes" | |
| "context" | |
| "encoding/json" | |
| "errors" | |
| "fmt" | |
| "io" | |
| "iter" | |
| "maps" | |
| "os" | |
| "os/exec" | |
| "runtime" | |
| "strings" | |
| "github.com/hashicorp/terraform-exec/internal/version" | |
| tfjson "github.com/hashicorp/terraform-json" | |
| ) | |
| const ( | |
| checkpointDisableEnvVar = "CHECKPOINT_DISABLE" | |
| cliArgsEnvVar = "TF_CLI_ARGS" | |
| inputEnvVar = "TF_INPUT" | |
| automationEnvVar = "TF_IN_AUTOMATION" | |
| logEnvVar = "TF_LOG" | |
| logCoreEnvVar = "TF_LOG_CORE" | |
| logPathEnvVar = "TF_LOG_PATH" | |
| logProviderEnvVar = "TF_LOG_PROVIDER" | |
| reattachEnvVar = "TF_REATTACH_PROVIDERS" | |
| appendUserAgentEnvVar = "TF_APPEND_USER_AGENT" | |
| workspaceEnvVar = "TF_WORKSPACE" | |
| disablePluginTLSEnvVar = "TF_DISABLE_PLUGIN_TLS" | |
| skipProviderVerifyEnvVar = "TF_SKIP_PROVIDER_VERIFY" | |
| varEnvVarPrefix = "TF_VAR_" | |
| cliArgEnvVarPrefix = "TF_CLI_ARGS_" | |
| ) | |
| var prohibitedEnvVars = []string{ | |
| cliArgsEnvVar, | |
| inputEnvVar, | |
| automationEnvVar, | |
| logEnvVar, | |
| logCoreEnvVar, | |
| logPathEnvVar, | |
| logProviderEnvVar, | |
| reattachEnvVar, | |
| appendUserAgentEnvVar, | |
| workspaceEnvVar, | |
| disablePluginTLSEnvVar, | |
| skipProviderVerifyEnvVar, | |
| } | |
| var prohibitedEnvVarPrefixes = []string{ | |
| varEnvVarPrefix, | |
| cliArgEnvVarPrefix, | |
| } | |
| func manualEnvVars(env map[string]string, cb func(k string)) { | |
| for k := range env { | |
| for _, p := range prohibitedEnvVars { | |
| if p == k { | |
| cb(k) | |
| goto NextEnvVar | |
| } | |
| } | |
| for _, prefix := range prohibitedEnvVarPrefixes { | |
| if strings.HasPrefix(k, prefix) { | |
| cb(k) | |
| goto NextEnvVar | |
| } | |
| } | |
| NextEnvVar: | |
| } | |
| } | |
| // ProhibitedEnv returns a slice of environment variable keys that are not allowed | |
| // to be set manually from the passed environment. | |
| func ProhibitedEnv(env map[string]string) []string { | |
| var p []string | |
| manualEnvVars(env, func(k string) { | |
| p = append(p, k) | |
| }) | |
| return p | |
| } | |
| // CleanEnv removes any prohibited environment variables from an environment map. | |
| func CleanEnv(dirty map[string]string) map[string]string { | |
| clean := dirty | |
| manualEnvVars(clean, func(k string) { | |
| delete(clean, k) | |
| }) | |
| return clean | |
| } | |
| func envMap(environ []string) map[string]string { | |
| env := map[string]string{} | |
| for _, ev := range environ { | |
| parts := strings.SplitN(ev, "=", 2) | |
| if len(parts) == 0 { | |
| continue | |
| } | |
| k := parts[0] | |
| v := "" | |
| if len(parts) == 2 { | |
| v = parts[1] | |
| } | |
| env[k] = v | |
| } | |
| return env | |
| } | |
| func envSlice(environ map[string]string) []string { | |
| env := []string{} | |
| for k, v := range environ { | |
| env = append(env, k+"="+v) | |
| } | |
| return env | |
| } | |
| // buildEnv determines which environment variables should affect use of a Terraform | |
| // executable. | |
| // | |
| // Factors that can affect the final set of environment variables are: | |
| // > Whether a user has explicitly set environment variables via (tf *Terraform) SetEnv. | |
| // > The environment of the machine terraform-exec is run on. | |
| // > Any override ENVs set in command-specific code (e.g. use of TF_REATTACH_PROVIDERS). | |
| // | |
| // This method also enforces some rules for the entire terraform-exec library, | |
| // for example User Agent data set via ENVs. | |
| func (tf *Terraform) buildEnv(mergeEnv map[string]string) []string { | |
| // set Terraform level env, if env is nil, fall back to os.Environ | |
| var env map[string]string | |
| if tf.env == nil { | |
| env = envMap(os.Environ()) | |
| } else { | |
| env = make(map[string]string, len(tf.env)) | |
| maps.Copy(env, tf.env) | |
| } | |
| // override env with any command specific environment | |
| maps.Copy(env, mergeEnv) | |
| // always propagate CHECKPOINT_DISABLE env var unless it is | |
| // explicitly overridden with tf.SetEnv or command env | |
| if _, ok := env[checkpointDisableEnvVar]; !ok { | |
| env[checkpointDisableEnvVar] = os.Getenv(checkpointDisableEnvVar) | |
| } | |
| // always override user agent | |
| ua := mergeUserAgent( | |
| os.Getenv(appendUserAgentEnvVar), | |
| tf.appendUserAgent, | |
| fmt.Sprintf("HashiCorp-terraform-exec/%s", version.ModuleVersion()), | |
| ) | |
| env[appendUserAgentEnvVar] = ua | |
| // always override logging | |
| if tf.logPath == "" { | |
| // so logging can't pollute our stderr output | |
| env[logEnvVar] = "" | |
| env[logCoreEnvVar] = "" | |
| env[logPathEnvVar] = "" | |
| env[logProviderEnvVar] = "" | |
| } else { | |
| env[logEnvVar] = tf.log | |
| env[logCoreEnvVar] = tf.logCore | |
| env[logPathEnvVar] = tf.logPath | |
| env[logProviderEnvVar] = tf.logProvider | |
| } | |
| // constant automation override env vars | |
| env[automationEnvVar] = "1" | |
| // force usage of workspace methods for switching | |
| delete(env, workspaceEnvVar) | |
| if tf.disablePluginTLS { | |
| env[disablePluginTLSEnvVar] = "1" | |
| } | |
| if tf.skipProviderVerify { | |
| env[skipProviderVerifyEnvVar] = "1" | |
| } | |
| return envSlice(env) | |
| } | |
| func (tf *Terraform) buildTerraformCmd(ctx context.Context, mergeEnv map[string]string, args ...string) *exec.Cmd { | |
| cmd := exec.CommandContext(ctx, tf.execPath, args...) | |
| cmd.Env = tf.buildEnv(mergeEnv) | |
| cmd.Dir = tf.workingDir | |
| if runtime.GOOS != "windows" { | |
| // Windows does not support SIGINT so we cannot do graceful cancellation | |
| // see https://pkg.go.dev/os#Signal (os.Interrupt) | |
| cmd.Cancel = func() error { | |
| return cmd.Process.Signal(os.Interrupt) | |
| } | |
| cmd.WaitDelay = tf.waitDelay | |
| } | |
| tf.logger.Printf("[INFO] running Terraform command: %s", cmd.String()) | |
| return cmd | |
| } | |
| // runTerraformCmdJSON runs a given command that produces a single, static JSON output. | |
| // This is decoded into the provided v argument. | |
| func (tf *Terraform) runTerraformCmdJSON(ctx context.Context, cmd *exec.Cmd, v interface{}) error { | |
| var outbuf = bytes.Buffer{} | |
| cmd.Stdout = mergeWriters(cmd.Stdout, &outbuf) | |
| err := tf.runTerraformCmd(ctx, cmd) | |
| if err != nil { | |
| return err | |
| } | |
| dec := json.NewDecoder(&outbuf) | |
| dec.UseNumber() | |
| return dec.Decode(v) | |
| } | |
| // runTerraformCmdJSONLog runs a given command that produces multiple, structured JSON log messages. | |
| // Due to this it returns an iterator that allows calling code to consume a stream of messages. | |
| func (tf *Terraform) runTerraformCmdJSONLog(ctx context.Context, cmd *exec.Cmd) iter.Seq[NextMessage] { | |
| pr, pw := io.Pipe() | |
| cmd.Stdout = mergeWriters(cmd.Stdout, pw) | |
| emitter := newLogMsgEmitter(pr) | |
| go func() { | |
| err := tf.runTerraformCmd(ctx, cmd) | |
| emitter.done <- errors.Join(err, pw.Close()) | |
| }() | |
| return func(yield func(msg NextMessage) bool) { | |
| for { | |
| nextMsg := emitter.NextMessage() | |
| ok := yield(nextMsg) | |
| if !ok || nextMsg.Msg == nil { | |
| return | |
| } | |
| } | |
| } | |
| } | |
| func newLogMsgEmitter(stdoutReader io.ReadCloser) *logMsgEmitter { | |
| return &logMsgEmitter{ | |
| scanner: bufio.NewScanner(stdoutReader), | |
| stdoutReader: stdoutReader, | |
| done: make(chan error, 1), | |
| } | |
| } | |
| type logMsgEmitter struct { | |
| scanner *bufio.Scanner | |
| stdoutReader io.Closer | |
| done chan error | |
| } | |
| type NextMessage struct { | |
| Msg tfjson.LogMsg | |
| Err error | |
| } | |
| // NextMessage returns next decoded message, if any, along with any errors. | |
| // Stdout reader is closed when the last message is received. | |
| // | |
| // Error returned can be related to decoding of the message, the Terraform command | |
| // or closing of stdout reader. | |
| // | |
| // Any error coming from Terraform (such as wrong configuration syntax) is | |
| // represented as LogMsg of Level [tfjson.Error]. | |
| func (e *logMsgEmitter) NextMessage() NextMessage { | |
| if e.scanner.Scan() { | |
| msg, err := tfjson.UnmarshalLogMessage(e.scanner.Bytes()) | |
| return NextMessage{ | |
| Msg: msg, | |
| Err: err, | |
| } | |
| } | |
| err := <-e.done | |
| err = errors.Join(err, e.scanner.Err(), e.stdoutReader.Close()) | |
| return NextMessage{ | |
| Msg: nil, | |
| Err: err, | |
| } | |
| } | |
| // mergeUserAgent does some minor deduplication to ensure we aren't | |
| // just using the same append string over and over. | |
| func mergeUserAgent(uas ...string) string { | |
| included := map[string]bool{} | |
| merged := []string{} | |
| for _, ua := range uas { | |
| ua = strings.TrimSpace(ua) | |
| if ua == "" { | |
| continue | |
| } | |
| if included[ua] { | |
| continue | |
| } | |
| included[ua] = true | |
| merged = append(merged, ua) | |
| } | |
| return strings.Join(merged, " ") | |
| } | |
| func mergeWriters(writers ...io.Writer) io.Writer { | |
| compact := []io.Writer{} | |
| for _, w := range writers { | |
| if w != nil { | |
| compact = append(compact, w) | |
| } | |
| } | |
| if len(compact) == 0 { | |
| return io.Discard | |
| } | |
| if len(compact) == 1 { | |
| return compact[0] | |
| } | |
| return io.MultiWriter(compact...) | |
| } | |
| func (tf *Terraform) writeOutput(ctx context.Context, r io.ReadCloser, w io.Writer) error { | |
| // ReadBytes will block until all bytes are read, which can cause a delay in | |
| // returning even if the command's context has been canceled. When the | |
| // context is canceled, Terraform receives an interrupt signal and will exit | |
| // after a short while. Once the process has exited, the stdio pipes will | |
| // close, allowing this function to return. | |
| if tf.enableLegacyPipeClosing { | |
| // Rather than wait for the stdio pipes to close naturally, we can close | |
| // them ourselves when the command's context is canceled, causing the | |
| // process to exit immediately. This works around a bug in Terraform | |
| // < v1.1 that would otherwise leave the process (and this function) | |
| // hanging after the context is canceled. | |
| closeCtx, closeCancel := context.WithCancel(ctx) | |
| defer closeCancel() | |
| go func() { | |
| select { | |
| case <-ctx.Done(): | |
| r.Close() | |
| case <-closeCtx.Done(): | |
| return | |
| } | |
| }() | |
| } | |
| buf := bufio.NewReader(r) | |
| for { | |
| line, err := buf.ReadBytes('\n') | |
| if len(line) > 0 { | |
| if _, err := w.Write(line); err != nil { | |
| return err | |
| } | |
| } | |
| if err != nil { | |
| if errors.Is(err, io.EOF) { | |
| return nil | |
| } | |
| return err | |
| } | |
| } | |
| } |