Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Env Var handling and TF_LOG_PATH support #32

Merged
merged 3 commits into from
Jul 20, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion tfexec/destroy.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ func (tf *Terraform) destroyCmd(ctx context.Context, opts ...DestroyOption) *exe
o.configureDestroy(&c)
}

args := []string{"destroy", "-no-color", "-auto-approve"}
args := []string{"destroy", "-no-color", "-auto-approve", "-input=false"}

// string opts: only pass if set
if c.backup != "" {
Expand Down
4 changes: 2 additions & 2 deletions tfexec/destroy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ func TestDestroyCmd(t *testing.T) {

actual := strings.TrimPrefix(cmdString(destroyCmd), destroyCmd.Path+" ")

expected := "destroy -no-color -auto-approve -lock-timeout=0s -lock=true -parallelism=10 -refresh=true"
expected := "destroy -no-color -auto-approve -input=false -lock-timeout=0s -lock=true -parallelism=10 -refresh=true"

if actual != expected {
t.Fatalf("expected default arguments of DestroyCmd:\n%s\n actual arguments:\n%s\n", expected, actual)
Expand All @@ -32,7 +32,7 @@ func TestDestroyCmd(t *testing.T) {

actual = strings.TrimPrefix(cmdString(destroyCmd), destroyCmd.Path+" ")

expected = "destroy -no-color -auto-approve -backup=testbackup -lock-timeout=200s -state=teststate -state-out=teststateout -var-file=testvarfile -lock=false -parallelism=99 -refresh=false -target=target1 -target=target2 -var 'var1=foo' -var 'var2=bar'"
expected = "destroy -no-color -auto-approve -input=false -backup=testbackup -lock-timeout=200s -state=teststate -state-out=teststateout -var-file=testvarfile -lock=false -parallelism=99 -refresh=false -target=target1 -target=target2 -var 'var1=foo' -var 'var2=bar'"

if actual != expected {
t.Fatalf("expected arguments of DestroyCmd:\n%s\n actual arguments:\n%s\n", expected, actual)
Expand Down
10 changes: 10 additions & 0 deletions tfexec/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,16 @@ func (e *ErrCLIUsage) Error() string {
return e.stderr
}

// ErrManualEnvVar is returned when an env var that should be set programatically via an option or method
// is set via the manual environment passing functions.
type ErrManualEnvVar struct {
name string
}

func (err *ErrManualEnvVar) Error() string {
return fmt.Sprintf("manual setting of env var %q detected", err.name)
}

// catchall error
type Err struct {
stderr string
Expand Down
44 changes: 29 additions & 15 deletions tfexec/terraform.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ type Terraform struct {
execPath string
workingDir string
execVersion string
env []string
logger *log.Logger
env map[string]string

logger *log.Logger
logPath string
}

// NewTerraform returns a Terraform struct with default values for all fields.
Expand All @@ -39,7 +41,7 @@ func NewTerraform(workingDir string, execPath string) (*Terraform, error) {
tf := Terraform{
execPath: execPath,
workingDir: workingDir,
env: os.Environ(),
env: nil, // explicit nil means copy os.Environ
logger: log.New(ioutil.Discard, "", 0),
}

Expand All @@ -53,28 +55,40 @@ func NewTerraform(workingDir string, execPath string) (*Terraform, error) {
return &tf, nil
}

func (tf *Terraform) SetEnv(env map[string]string) {
var tfenv []string

// always propagate CHECKPOINT_DISABLE env var unless it is
// explicitly overridden with tf.SetEnv
if _, ok := env["CHECKPOINT_DISABLE"]; !ok {
env["CHECKPOINT_DISABLE"] = os.Getenv("CHECKPOINT_DISABLE")
}

for k, v := range env {
tfenv = append(tfenv, k+"="+v)
// SetEnv allows you to override environment variables, this should not be used for any well known
// Terraform environment variables that are already covered in options. Pass nil to copy the values
// from os.Environ. Attempting to set environment variables that should be managed manually will
// result in ErrManualEnvVar being returned.
func (tf *Terraform) SetEnv(env map[string]string) error {
for k := range env {
if strings.HasPrefix(k, varEnvVarPrefix) {
return fmt.Errorf("variables should be passed using the Var option: %w", &ErrManualEnvVar{k})
}
for _, p := range prohibitedEnvVars {
if p == k {
return &ErrManualEnvVar{k}
}
}
}

tf.env = tfenv
tf.env = env
return nil
}

func (tf *Terraform) SetLogger(logger *log.Logger) {
tf.logger = logger
}

// SetLogPath sets the TF_LOG_PATH environment variable for Terraform CLI
// execution.
func (tf *Terraform) SetLogPath(path string) error {
tf.logPath = path
return nil
}

func (tf *Terraform) version() (string, error) {
versionCmd := tf.buildTerraformCmd(context.Background(), "version")

var errBuf strings.Builder
var outBuf bytes.Buffer
versionCmd.Stderr = &errBuf
Expand Down
84 changes: 82 additions & 2 deletions tfexec/terraform_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,97 @@ package tfexec

import (
"context"
"os"
"os/exec"
"strings"
)

const (
checkpointDisableEnvVar = "CHECKPOINT_DISABLE"
logEnvVar = "TF_LOG"
inputEnvVar = "TF_INPUT"
automationEnvVar = "TF_IN_AUTOMATION"
logPathEnvVar = "TF_LOG_PATH"

varEnvVarPrefix = "TF_VAR_"
)

// probhitiedEnvVars are the list of variables that cause an error when
// passed explicitly via SetEnv and are also elided when already existing
// in the current environment.
var prohibitedEnvVars = []string{
inputEnvVar,
automationEnvVar,
logPathEnvVar,
logEnvVar,
}

func environ() map[string]string {
env := map[string]string{}
for _, ev := range os.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 (tf *Terraform) buildEnv() []string {
var menv map[string]string
if tf.env == nil {
menv = environ()
// remove any prohibited env vars from environ
for _, k := range prohibitedEnvVars {
delete(menv, k)
}
} else {
menv = make(map[string]string, len(tf.env))
for k, v := range tf.env {
menv[k] = v
}
}

if _, ok := menv[checkpointDisableEnvVar]; !ok {
// always propagate CHECKPOINT_DISABLE env var unless it is
// explicitly overridden with tf.SetEnv
menv[checkpointDisableEnvVar] = os.Getenv(checkpointDisableEnvVar)
}

if tf.logPath == "" {
// so logging can't pollute our stderr output
menv[logEnvVar] = ""
menv[logPathEnvVar] = ""
} else {
menv[logPathEnvVar] = tf.logPath
// Log levels other than TRACE are currently unreliable, the CLI recommends using TRACE only.
menv[logEnvVar] = "TRACE"
}

menv[automationEnvVar] = "1"

env := []string{}
for k, v := range menv {
env = append(env, k+"="+v)
}

return env
}

func (tf *Terraform) buildTerraformCmd(ctx context.Context, args ...string) *exec.Cmd {
env := append(tf.env, "TF_LOG=") // so logging can't pollute our stderr output
env := tf.buildEnv()

cmd := exec.CommandContext(ctx, tf.execPath, args...)
cmd.Env = env
cmd.Dir = tf.workingDir

tf.logger.Printf("Terraform command: %s", cmdString(cmd))
tf.logger.Printf("[INFO] running Terraform command: %s", cmdString(cmd))

return cmd
}
51 changes: 47 additions & 4 deletions tfexec/terraform_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package tfexec

import (
"context"
"errors"
"io"
"io/ioutil"
"os"
Expand Down Expand Up @@ -30,6 +31,41 @@ func TestMain(m *testing.M) {
}())
}

func TestSetEnv(t *testing.T) {
td := testTempDir(t)
defer os.RemoveAll(td)

tf, err := NewTerraform(td, tfVersion(t, "0.12.28"))
if err != nil {
t.Fatal(err)
}

for _, c := range []struct {
errManual bool
name string
}{
{false, "OK_ENV_VAR"},

{true, "TF_LOG"},
{true, "TF_VAR_foo"},
} {
t.Run(c.name, func(t *testing.T) {
err = tf.SetEnv(map[string]string{c.name: "foo"})

if c.errManual {
var evErr *ErrManualEnvVar
if !errors.As(err, &evErr) {
t.Fatalf("expected ErrManualEnvVar, got %T %s", err, err)
}
} else {
if !c.errManual && err != nil {
t.Fatal(err)
}
}
})
}
}

func TestCheckpointDisablePropagation(t *testing.T) {
td := testTempDir(t)
defer os.RemoveAll(td)
Expand All @@ -40,13 +76,17 @@ func TestCheckpointDisablePropagation(t *testing.T) {
}

// case 1: env var is set in environment and not overridden
os.Setenv("CHECKPOINT_DISABLE", "1")
err = os.Setenv("CHECKPOINT_DISABLE", "1")
if err != nil {
t.Fatal(err)
}
defer os.Unsetenv("CHECKPOINT_DISABLE")

tf.SetEnv(map[string]string{
"FOOBAR": "1",
})
initCmd := tf.initCmd(context.Background())
expected := []string{"CHECKPOINT_DISABLE=1", "FOOBAR=1", "TF_LOG="}
expected := []string{"CHECKPOINT_DISABLE=1", "FOOBAR=1", "TF_IN_AUTOMATION=1", "TF_LOG=", "TF_LOG_PATH="}
s := initCmd.Env
sort.Strings(s)
actual := s
Expand All @@ -56,12 +96,15 @@ func TestCheckpointDisablePropagation(t *testing.T) {
}

// case 2: env var is set in environment and overridden with SetEnv
tf.SetEnv(map[string]string{
err = tf.SetEnv(map[string]string{
"CHECKPOINT_DISABLE": "",
"FOOBAR": "1",
})
if err != nil {
t.Fatal(err)
}
initCmd = tf.initCmd(context.Background())
expected = []string{"CHECKPOINT_DISABLE=", "FOOBAR=1", "TF_LOG="}
expected = []string{"CHECKPOINT_DISABLE=", "FOOBAR=1", "TF_IN_AUTOMATION=1", "TF_LOG=", "TF_LOG_PATH="}
s = initCmd.Env
sort.Strings(s)
actual = s
Expand Down