diff --git a/tfexec/apply.go b/tfexec/apply.go new file mode 100644 index 00000000..9559b9a8 --- /dev/null +++ b/tfexec/apply.go @@ -0,0 +1,146 @@ +package tfexec + +import ( + "context" + "fmt" + "os/exec" + "strconv" + "strings" +) + +type applyConfig struct { + backup string + dirOrPlan string + lock bool + + // LockTimeout must be a string with time unit, e.g. '10s' + lockTimeout string + parallelism int + refresh bool + state string + stateOut string + targets []string + + // Vars: each var must be supplied as a single string, e.g. 'foo=bar' + vars []string + varFile string +} + +var defaultApplyOptions = applyConfig{ + lock: true, + parallelism: 10, + refresh: true, +} + +type ApplyOption interface { + configureApply(*applyConfig) +} + +func (opt *ParallelismOption) configureApply(conf *applyConfig) { + conf.parallelism = opt.parallelism +} + +func (opt *BackupOption) configureApply(conf *applyConfig) { + conf.backup = opt.path +} + +func (opt *TargetOption) configureApply(conf *applyConfig) { + conf.targets = append(conf.targets, opt.target) +} + +func (opt *LockTimeoutOption) configureApply(conf *applyConfig) { + conf.lockTimeout = opt.timeout +} + +func (opt *StateOption) configureApply(conf *applyConfig) { + conf.state = opt.path +} + +func (opt *StateOutOption) configureApply(conf *applyConfig) { + conf.stateOut = opt.path +} + +func (opt *VarFileOption) configureApply(conf *applyConfig) { + conf.varFile = opt.path +} + +func (opt *LockOption) configureApply(conf *applyConfig) { + conf.lock = opt.lock +} + +func (opt *RefreshOption) configureApply(conf *applyConfig) { + conf.refresh = opt.refresh +} + +func (opt *VarOption) configureApply(conf *applyConfig) { + conf.vars = append(conf.vars, opt.assignment) +} + +func (opt *DirOrPlanOption) configureApply(conf *applyConfig) { + conf.dirOrPlan = opt.path +} + +func (tf *Terraform) Apply(ctx context.Context, opts ...ApplyOption) error { + applyCmd := tf.ApplyCmd(ctx, opts...) + + var errBuf strings.Builder + applyCmd.Stderr = &errBuf + + err := applyCmd.Run() + if err != nil { + return parseError(errBuf.String()) + } + + return nil +} + +func (tf *Terraform) ApplyCmd(ctx context.Context, opts ...ApplyOption) *exec.Cmd { + c := defaultApplyOptions + + for _, o := range opts { + o.configureApply(&c) + } + + args := []string{"apply", "-no-color", "-auto-approve", "-input=false"} + + // string opts: only pass if set + if c.backup != "" { + args = append(args, "-backup="+c.backup) + } + if c.lockTimeout != "" { + args = append(args, "-lock-timeout="+c.lockTimeout) + } + if c.state != "" { + args = append(args, "-state="+c.state) + } + if c.stateOut != "" { + args = append(args, "-state-out="+c.stateOut) + } + if c.varFile != "" { + args = append(args, "-var-file="+c.varFile) + } + + // boolean and numerical opts: always pass + args = append(args, "-lock="+strconv.FormatBool(c.lock)) + args = append(args, "-parallelism="+fmt.Sprint(c.parallelism)) + args = append(args, "-refresh="+strconv.FormatBool(c.refresh)) + + // string slice opts: split into separate args + if c.targets != nil { + for _, ta := range c.targets { + args = append(args, "-target="+ta) + } + } + if c.vars != nil { + for _, v := range c.vars { + args = append(args, "-var '"+v+"'") + } + } + + // string argument: pass if set + if c.dirOrPlan != "" { + args = append(args, c.dirOrPlan) + } + + return tf.buildTerraformCmd(ctx, args...) +} diff --git a/tfexec/apply_test.go b/tfexec/apply_test.go new file mode 100644 index 00000000..e3ad561a --- /dev/null +++ b/tfexec/apply_test.go @@ -0,0 +1,54 @@ +package tfexec + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestApply(t *testing.T) { + td := testTempDir(t) + defer os.RemoveAll(td) + + tf, err := NewTerraform(td, tfPath) + if err != nil { + t.Fatal(err) + } + + err = copyFile(filepath.Join(testFixtureDir, testConfigFileName), filepath.Join(td, testConfigFileName)) + if err != nil { + t.Fatalf("error copying config file into test dir: %s", err) + } + + err = tf.Init(context.Background()) + if err != nil { + t.Fatalf("error running Init in test directory: %s", err) + } + + err = tf.Apply(context.Background()) + if err != nil { + t.Fatalf("error running Apply: %s", err) + } +} + +func TestApplyCmd(t *testing.T) { + td := testTempDir(t) + defer os.RemoveAll(td) + + tf, err := NewTerraform(td, tfPath) + if err != nil { + t.Fatal(err) + } + + applyCmd := tf.ApplyCmd(context.Background(), Backup("testbackup"), LockTimeout("200s"), State("teststate"), StateOut("teststateout"), VarFile("testvarfile"), Lock(false), Parallelism(99), Refresh(false), Target("target1"), Target("target2"), Var("var1=foo"), Var("var2=bar"), DirOrPlan("testfile")) + + actual := strings.TrimPrefix(cmdString(applyCmd), applyCmd.Path+" ") + + expected := "apply -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' testfile" + + if actual != expected { + t.Fatalf("expected arguments of ApplyCmd:\n%s\n actual arguments:\n%s\n", expected, actual) + } +} diff --git a/tfexec/destroy.go b/tfexec/destroy.go new file mode 100644 index 00000000..d7b181d5 --- /dev/null +++ b/tfexec/destroy.go @@ -0,0 +1,137 @@ +package tfexec + +import ( + "context" + "fmt" + "os/exec" + "strconv" + "strings" +) + +type destroyConfig struct { + backup string + lock bool + + // LockTimeout must be a string with time unit, e.g. '10s' + lockTimeout string + parallelism int + refresh bool + state string + stateOut string + targets []string + + // Vars: each var must be supplied as a single string, e.g. 'foo=bar' + vars []string + varFile string +} + +var defaultDestroyOptions = destroyConfig{ + lock: true, + lockTimeout: "0s", + parallelism: 10, + refresh: true, +} + +type DestroyOption interface { + configureDestroy(*destroyConfig) +} + +func (opt *ParallelismOption) configureDestroy(conf *destroyConfig) { + conf.parallelism = opt.parallelism +} + +func (opt *BackupOption) configureDestroy(conf *destroyConfig) { + conf.backup = opt.path +} + +func (opt *TargetOption) configureDestroy(conf *destroyConfig) { + conf.targets = append(conf.targets, opt.target) +} + +func (opt *LockTimeoutOption) configureDestroy(conf *destroyConfig) { + conf.lockTimeout = opt.timeout +} + +func (opt *StateOption) configureDestroy(conf *destroyConfig) { + conf.state = opt.path +} + +func (opt *StateOutOption) configureDestroy(conf *destroyConfig) { + conf.stateOut = opt.path +} + +func (opt *VarFileOption) configureDestroy(conf *destroyConfig) { + conf.varFile = opt.path +} + +func (opt *LockOption) configureDestroy(conf *destroyConfig) { + conf.lock = opt.lock +} + +func (opt *RefreshOption) configureDestroy(conf *destroyConfig) { + conf.refresh = opt.refresh +} + +func (opt *VarOption) configureDestroy(conf *destroyConfig) { + conf.vars = append(conf.vars, opt.assignment) +} + +func (tf *Terraform) Destroy(ctx context.Context, opts ...DestroyOption) error { + destroyCmd := tf.DestroyCmd(ctx, opts...) + + var errBuf strings.Builder + destroyCmd.Stderr = &errBuf + + err := destroyCmd.Run() + if err != nil { + return parseError(errBuf.String()) + } + + return nil +} + +func (tf *Terraform) DestroyCmd(ctx context.Context, opts ...DestroyOption) *exec.Cmd { + c := defaultDestroyOptions + + for _, o := range opts { + o.configureDestroy(&c) + } + + args := []string{"destroy", "-no-color", "-auto-approve"} + + // string opts: only pass if set + if c.backup != "" { + args = append(args, "-backup="+c.backup) + } + if c.lockTimeout != "" { + args = append(args, "-lock-timeout="+c.lockTimeout) + } + if c.state != "" { + args = append(args, "-state="+c.state) + } + if c.stateOut != "" { + args = append(args, "-state-out="+c.stateOut) + } + if c.varFile != "" { + args = append(args, "-var-file="+c.varFile) + } + + // boolean and numerical opts: always pass + args = append(args, "-lock="+strconv.FormatBool(c.lock)) + args = append(args, "-parallelism="+fmt.Sprint(c.parallelism)) + args = append(args, "-refresh="+strconv.FormatBool(c.refresh)) + + // string slice opts: split into separate args + if c.targets != nil { + for _, ta := range c.targets { + args = append(args, "-target="+ta) + } + } + if c.vars != nil { + for _, v := range c.vars { + args = append(args, "-var '"+v+"'") + } + } + + return tf.buildTerraformCmd(ctx, args...) +} diff --git a/tfexec/destroy_test.go b/tfexec/destroy_test.go new file mode 100644 index 00000000..bda9972f --- /dev/null +++ b/tfexec/destroy_test.go @@ -0,0 +1,40 @@ +package tfexec + +import ( + "context" + "os" + "strings" + "testing" +) + +func TestDestroyCmd(t *testing.T) { + td := testTempDir(t) + defer os.RemoveAll(td) + + tf, err := NewTerraform(td, tfPath) + if err != nil { + t.Fatal(err) + } + + // defaults + destroyCmd := tf.DestroyCmd(context.Background()) + + actual := strings.TrimPrefix(cmdString(destroyCmd), destroyCmd.Path+" ") + + expected := "destroy -no-color -auto-approve -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) + } + + // override all defaults + destroyCmd = tf.DestroyCmd(context.Background(), Backup("testbackup"), LockTimeout("200s"), State("teststate"), StateOut("teststateout"), VarFile("testvarfile"), Lock(false), Parallelism(99), Refresh(false), Target("target1"), Target("target2"), Var("var1=foo"), Var("var2=bar")) + + 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'" + + if actual != expected { + t.Fatalf("expected arguments of DestroyCmd:\n%s\n actual arguments:\n%s\n", expected, actual) + } +} diff --git a/tfexec/import.go b/tfexec/import.go new file mode 100644 index 00000000..bd204ca2 --- /dev/null +++ b/tfexec/import.go @@ -0,0 +1,137 @@ +package tfexec + +import ( + "context" + "os/exec" + "strconv" + "strings" +) + +type importConfig struct { + addr string + id string + backup string + config string + allowMissingConfig bool + lock bool + lockTimeout string + state string + stateOut string + vars []string + varFile string +} + +var defaultImportOptions = importConfig{ + allowMissingConfig: false, + lock: true, + lockTimeout: "0s", +} + +type ImportOption interface { + configureImport(*importConfig) +} + +func (opt *AddrOption) configureImport(conf *importConfig) { + conf.addr = opt.addr +} + +func (opt *IdOption) configureImport(conf *importConfig) { + conf.id = opt.id +} + +func (opt *BackupOption) configureImport(conf *importConfig) { + conf.backup = opt.path +} + +func (opt *ConfigOption) configureImport(conf *importConfig) { + conf.config = opt.path +} + +func (opt *AllowMissingConfigOption) configureImport(conf *importConfig) { + conf.allowMissingConfig = opt.allowMissingConfig +} + +func (opt *LockOption) configureImport(conf *importConfig) { + conf.lock = opt.lock +} + +func (opt *LockTimeoutOption) configureImport(conf *importConfig) { + conf.lockTimeout = opt.timeout +} + +func (opt *StateOption) configureImport(conf *importConfig) { + conf.state = opt.path +} + +func (opt *StateOutOption) configureImport(conf *importConfig) { + conf.stateOut = opt.path +} + +func (opt *VarOption) configureImport(conf *importConfig) { + conf.vars = append(conf.vars, opt.assignment) +} + +func (opt *VarFileOption) configureImport(conf *importConfig) { + conf.varFile = opt.path +} + +func (t *Terraform) Import(ctx context.Context, opts ...ImportOption) error { + importCmd := t.ImportCmd(ctx, opts...) + + var errBuf strings.Builder + importCmd.Stderr = &errBuf + + err := importCmd.Run() + if err != nil { + return parseError(errBuf.String()) + } + + return nil +} + +func (tf *Terraform) ImportCmd(ctx context.Context, opts ...ImportOption) *exec.Cmd { + c := defaultImportOptions + + for _, o := range opts { + o.configureImport(&c) + } + + args := []string{"import", "-no-color", "-input=false"} + + // string opts: only pass if set + if c.backup != "" { + args = append(args, "-backup="+c.backup) + } + if c.config != "" { + args = append(args, "-config"+c.config) + } + if c.lockTimeout != "" { + args = append(args, "-lock-timeout="+c.lockTimeout) + } + if c.state != "" { + args = append(args, "-state="+c.state) + } + if c.stateOut != "" { + args = append(args, "-state-out="+c.stateOut) + } + if c.varFile != "" { + args = append(args, "-var-file="+c.varFile) + } + + // boolean and numerical opts: always pass + args = append(args, "-lock="+strconv.FormatBool(c.lock)) + + // unary flags: pass if true + if c.allowMissingConfig { + args = append(args, "-allow-missing-config") + } + + // string slice opts: split into separate args + if c.vars != nil { + for _, v := range c.vars { + args = append(args, "-var '"+v+"'") + } + } + + return tf.buildTerraformCmd(ctx, args...) +} diff --git a/tfexec/import_test.go b/tfexec/import_test.go new file mode 100644 index 00000000..9cdb81d0 --- /dev/null +++ b/tfexec/import_test.go @@ -0,0 +1,49 @@ +package tfexec + +import ( + "context" + "os" + "strings" + "testing" +) + +func TestImportCmd(t *testing.T) { + td := testTempDir(t) + defer os.RemoveAll(td) + + tf, err := NewTerraform(td, tfPath) + if err != nil { + t.Fatal(err) + } + + // defaults + importCmd := tf.ImportCmd(context.Background()) + + actual := strings.TrimPrefix(cmdString(importCmd), importCmd.Path+" ") + + expected := "import -no-color -input=false -lock-timeout=0s -lock=true" + + if actual != expected { + t.Fatalf("expected default arguments of ImportCmd:\n%s\n actual arguments:\n%s\n", expected, actual) + } + + // override all defaults + importCmd = tf.ImportCmd(context.Background(), + Backup("testbackup"), + LockTimeout("200s"), + State("teststate"), + StateOut("teststateout"), + VarFile("testvarfile"), + Lock(false), + Var("var1=foo"), + Var("var2=bar"), + AllowMissingConfig(true)) + + actual = strings.TrimPrefix(cmdString(importCmd), importCmd.Path+" ") + + expected = "import -no-color -input=false -backup=testbackup -lock-timeout=200s -state=teststate -state-out=teststateout -var-file=testvarfile -lock=false -allow-missing-config -var 'var1=foo' -var 'var2=bar'" + + if actual != expected { + t.Fatalf("expected arguments of ImportCmd:\n%s\n actual arguments:\n%s\n", expected, actual) + } +} diff --git a/tfexec/init.go b/tfexec/init.go new file mode 100644 index 00000000..0e3b99ee --- /dev/null +++ b/tfexec/init.go @@ -0,0 +1,142 @@ +package tfexec + +import ( + "context" + "fmt" + "os/exec" + "strings" +) + +type initConfig struct { + backend bool + backendConfig []string + forceCopy bool + fromModule string + get bool + getPlugins bool + lock bool + lockTimeout string + pluginDir []string + reconfigure bool + upgrade bool + verifyPlugins bool +} + +var defaultInitOptions = initConfig{ + backend: true, + forceCopy: false, + get: true, + getPlugins: true, + lock: true, + lockTimeout: "0s", + reconfigure: false, + upgrade: false, + verifyPlugins: true, +} + +type InitOption interface { + configureInit(*initConfig) +} + +func (opt *BackendOption) configureInit(conf *initConfig) { + conf.backend = opt.backend +} + +func (opt *BackendConfigOption) configureInit(conf *initConfig) { + conf.backendConfig = append(conf.backendConfig, opt.path) +} + +func (opt *FromModuleOption) configureInit(conf *initConfig) { + conf.fromModule = opt.source +} + +func (opt *GetOption) configureInit(conf *initConfig) { + conf.get = opt.get +} + +func (opt *GetPluginsOption) configureInit(conf *initConfig) { + conf.getPlugins = opt.getPlugins +} + +func (opt *LockOption) configureInit(conf *initConfig) { + conf.lock = opt.lock +} + +func (opt *LockTimeoutOption) configureInit(conf *initConfig) { + conf.lockTimeout = opt.timeout +} + +func (opt *PluginDirOption) configureInit(conf *initConfig) { + conf.pluginDir = append(conf.pluginDir, opt.pluginDir) +} + +func (opt *ReconfigureOption) configureInit(conf *initConfig) { + conf.reconfigure = opt.reconfigure +} + +func (opt *UpgradeOption) configureInit(conf *initConfig) { + conf.upgrade = opt.upgrade +} + +func (opt *VerifyPluginsOption) configureInit(conf *initConfig) { + conf.verifyPlugins = opt.verifyPlugins +} + +func (t *Terraform) Init(ctx context.Context, opts ...InitOption) error { + initCmd := t.InitCmd(ctx, opts...) + + var errBuf strings.Builder + initCmd.Stderr = &errBuf + + err := initCmd.Run() + if err != nil { + return parseError(errBuf.String()) + } + + return nil +} + +func (tf *Terraform) InitCmd(ctx context.Context, opts ...InitOption) *exec.Cmd { + c := defaultInitOptions + + for _, o := range opts { + o.configureInit(&c) + } + + args := []string{"init", "-no-color", "-force-copy", "-input=false"} + + // string opts: only pass if set + if c.fromModule != "" { + args = append(args, "-from-module="+c.fromModule) + } + if c.lockTimeout != "" { + args = append(args, "-lock-timeout="+c.lockTimeout) + } + + // boolean opts: always pass + args = append(args, "-backend="+fmt.Sprint(c.backend)) + args = append(args, "-get="+fmt.Sprint(c.get)) + args = append(args, "-get-plugins="+fmt.Sprint(c.getPlugins)) + args = append(args, "-lock="+fmt.Sprint(c.lock)) + args = append(args, "-upgrade="+fmt.Sprint(c.upgrade)) + args = append(args, "-verify-plugins="+fmt.Sprint(c.verifyPlugins)) + + // unary flags: pass if true + if c.reconfigure { + args = append(args, "-reconfigure") + } + + // string slice opts: split into separate args + if c.backendConfig != nil { + for _, bc := range c.backendConfig { + args = append(args, "-backend-config="+bc) + } + } + if c.pluginDir != nil { + for _, pd := range c.pluginDir { + args = append(args, "-plugin-dir="+pd) + } + } + + return tf.buildTerraformCmd(ctx, args...) +} diff --git a/tfexec/init_test.go b/tfexec/init_test.go new file mode 100644 index 00000000..d86a6833 --- /dev/null +++ b/tfexec/init_test.go @@ -0,0 +1,40 @@ +package tfexec + +import ( + "context" + "os" + "strings" + "testing" +) + +func TestInitCmd(t *testing.T) { + td := testTempDir(t) + defer os.RemoveAll(td) + + tf, err := NewTerraform(td, tfPath) + if err != nil { + t.Fatal(err) + } + + // defaults + initCmd := tf.InitCmd(context.Background()) + + actual := strings.TrimPrefix(cmdString(initCmd), initCmd.Path+" ") + + expected := "init -no-color -force-copy -input=false -lock-timeout=0s -backend=true -get=true -get-plugins=true -lock=true -upgrade=false -verify-plugins=true" + + if actual != expected { + t.Fatalf("expected default arguments of InitCmd:\n%s\n actual arguments:\n%s\n", expected, actual) + } + + // override all defaults + initCmd = tf.InitCmd(context.Background(), Backend(false), BackendConfig("confpath1"), BackendConfig("confpath2"), FromModule("testsource"), Get(false), GetPlugins(false), Lock(false), LockTimeout("999s"), PluginDir("testdir1"), PluginDir("testdir2"), Reconfigure(true), Upgrade(true), VerifyPlugins(false)) + + actual = strings.TrimPrefix(cmdString(initCmd), initCmd.Path+" ") + + expected = "init -no-color -force-copy -input=false -from-module=testsource -lock-timeout=999s -backend=false -get=false -get-plugins=false -lock=false -upgrade=true -verify-plugins=false -reconfigure -backend-config=confpath1 -backend-config=confpath2 -plugin-dir=testdir1 -plugin-dir=testdir2" + + if actual != expected { + t.Fatalf("expected arguments of InitCmd:\n%s\n actual arguments:\n%s\n", expected, actual) + } +} diff --git a/tfexec/output.go b/tfexec/output.go new file mode 100644 index 00000000..202605f2 --- /dev/null +++ b/tfexec/output.go @@ -0,0 +1,75 @@ +package tfexec + +import ( + "bytes" + "context" + "encoding/json" + "os/exec" + "strings" +) + +type outputConfig struct { + state string + json bool +} + +var defaultOutputOptions = outputConfig{} + +type OutputOption interface { + configureOutput(*outputConfig) +} + +func (opt *StateOption) configureOutput(conf *outputConfig) { + conf.state = opt.path +} + +// OutputMeta represents the JSON output of 'terraform output -json', +// which resembles state format version 3 due to a historical accident. +// Please see hashicorp/terraform/command/output.go. +// TODO KEM: Should this type be in terraform-json? +type OutputMeta struct { + Sensitive bool `json:"sensitive"` + Type json.RawMessage `json:"type"` + Value json.RawMessage `json:"value"` +} + +func (tf *Terraform) Output(ctx context.Context, opts ...OutputOption) (map[string]OutputMeta, error) { + outputCmd := tf.OutputCmd(ctx, opts...) + + var errBuf strings.Builder + var outBuf bytes.Buffer + + outputCmd.Stderr = &errBuf + outputCmd.Stdout = &outBuf + + outputs := map[string]OutputMeta{} + + err := outputCmd.Run() + if err != nil { + return nil, parseError(err.Error()) + } + + err = json.Unmarshal(outBuf.Bytes(), &outputs) + if err != nil { + return nil, err + } + + return outputs, nil +} + +func (tf *Terraform) OutputCmd(ctx context.Context, opts ...OutputOption) *exec.Cmd { + c := defaultOutputOptions + + for _, o := range opts { + o.configureOutput(&c) + } + + args := []string{"output", "-no-color", "-json"} + + // string opts: only pass if set + if c.state != "" { + args = append(args, "-state="+c.state) + } + + return tf.buildTerraformCmd(ctx, args...) +} diff --git a/tfexec/output_test.go b/tfexec/output_test.go new file mode 100644 index 00000000..bb488a61 --- /dev/null +++ b/tfexec/output_test.go @@ -0,0 +1,41 @@ +package tfexec + +import ( + "context" + "os" + "strings" + "testing" +) + +func TestOutputCmd(t *testing.T) { + td := testTempDir(t) + defer os.RemoveAll(td) + + tf, err := NewTerraform(td, tfPath) + if err != nil { + t.Fatal(err) + } + + // defaults + outputCmd := tf.OutputCmd(context.Background()) + + actual := strings.TrimPrefix(cmdString(outputCmd), outputCmd.Path+" ") + + expected := "output -no-color -json" + + if actual != expected { + t.Fatalf("expected default arguments of OutputCmd:\n%s\n actual arguments:\n%s\n", expected, actual) + } + + // override all defaults + outputCmd = tf.OutputCmd(context.Background(), + State("teststate")) + + actual = strings.TrimPrefix(cmdString(outputCmd), outputCmd.Path+" ") + + expected = "output -no-color -json -state=teststate" + + if actual != expected { + t.Fatalf("expected arguments of ImportCmd:\n%s\n actual arguments:\n%s\n", expected, actual) + } +} diff --git a/tfexec/plan.go b/tfexec/plan.go new file mode 100644 index 00000000..e8a4431b --- /dev/null +++ b/tfexec/plan.go @@ -0,0 +1,136 @@ +package tfexec + +import ( + "context" + "fmt" + "os/exec" + "strconv" + "strings" +) + +type planConfig struct { + destroy bool + lock bool + lockTimeout string + out string + parallelism int + refresh bool + state string + targets []string + vars []string + varFile string +} + +var defaultPlanOptions = planConfig{ + destroy: false, + lock: true, + lockTimeout: "0s", + parallelism: 10, + refresh: true, +} + +type PlanOption interface { + configurePlan(*planConfig) +} + +func (opt *VarFileOption) configurePlan(conf *planConfig) { + conf.varFile = opt.path +} + +func (opt *VarOption) configurePlan(conf *planConfig) { + conf.vars = append(conf.vars, opt.assignment) +} + +func (opt *TargetOption) configurePlan(conf *planConfig) { + conf.targets = append(conf.targets, opt.target) +} + +func (opt *StateOption) configurePlan(conf *planConfig) { + conf.state = opt.path +} + +func (opt *RefreshOption) configurePlan(conf *planConfig) { + conf.refresh = opt.refresh +} + +func (opt *ParallelismOption) configurePlan(conf *planConfig) { + conf.parallelism = opt.parallelism +} + +func (opt *OutOption) configurePlan(conf *planConfig) { + conf.out = opt.path +} + +func (opt *LockTimeoutOption) configurePlan(conf *planConfig) { + conf.lockTimeout = opt.timeout +} + +func (opt *LockOption) configurePlan(conf *planConfig) { + conf.lock = opt.lock +} + +func (opt *DestroyFlagOption) configurePlan(conf *planConfig) { + conf.destroy = opt.destroy +} + +func (tf *Terraform) Plan(ctx context.Context, opts ...PlanOption) error { + planCmd := tf.PlanCmd(ctx, opts...) + + var errBuf strings.Builder + planCmd.Stderr = &errBuf + + err := planCmd.Run() + if err != nil { + return parseError(errBuf.String()) + } + + return nil +} + +func (tf *Terraform) PlanCmd(ctx context.Context, opts ...PlanOption) *exec.Cmd { + c := defaultPlanOptions + + for _, o := range opts { + o.configurePlan(&c) + } + + args := []string{"plan", "-no-color", "-input=false"} + + // string opts: only pass if set + if c.lockTimeout != "" { + args = append(args, "-lock-timeout="+c.lockTimeout) + } + if c.out != "" { + args = append(args, "-out="+c.out) + } + if c.state != "" { + args = append(args, "-state="+c.state) + } + if c.varFile != "" { + args = append(args, "-var-file="+c.varFile) + } + + // boolean and numerical opts: always pass + args = append(args, "-lock="+strconv.FormatBool(c.lock)) + args = append(args, "-parallelism="+fmt.Sprint(c.parallelism)) + args = append(args, "-refresh="+strconv.FormatBool(c.refresh)) + + // unary flags: pass if true + if c.destroy { + args = append(args, "-destroy") + } + + // string slice opts: split into separate args + if c.targets != nil { + for _, ta := range c.targets { + args = append(args, "-target="+ta) + } + } + if c.vars != nil { + for _, v := range c.vars { + args = append(args, "-var '"+v+"'") + } + } + + return tf.buildTerraformCmd(ctx, args...) +} diff --git a/tfexec/plan_test.go b/tfexec/plan_test.go new file mode 100644 index 00000000..b125c7a7 --- /dev/null +++ b/tfexec/plan_test.go @@ -0,0 +1,40 @@ +package tfexec + +import ( + "context" + "os" + "strings" + "testing" +) + +func TestPlanCmd(t *testing.T) { + td := testTempDir(t) + defer os.RemoveAll(td) + + tf, err := NewTerraform(td, tfPath) + if err != nil { + t.Fatal(err) + } + + // defaults + planCmd := tf.PlanCmd(context.Background()) + + actual := strings.TrimPrefix(cmdString(planCmd), planCmd.Path+" ") + + expected := "plan -no-color -input=false -lock-timeout=0s -lock=true -parallelism=10 -refresh=true" + + if actual != expected { + t.Fatalf("expected default arguments of PlanCmd:\n%s\n actual arguments:\n%s\n", expected, actual) + } + + // override all defaults + planCmd = tf.PlanCmd(context.Background(), Destroy(true), Lock(false), LockTimeout("22s"), Out("whale"), Parallelism(42), Refresh(false), State("marvin"), Target("zaphod"), Target("beeblebrox"), Var("android=paranoid"), Var("brain_size=planet"), VarFile("trillian")) + + actual = strings.TrimPrefix(cmdString(planCmd), planCmd.Path+" ") + + expected = "plan -no-color -input=false -lock-timeout=22s -out=whale -state=marvin -var-file=trillian -lock=false -parallelism=42 -refresh=false -destroy -target=zaphod -target=beeblebrox -var 'android=paranoid' -var 'brain_size=planet'" + + if actual != expected { + t.Fatalf("expected arguments of PlanCmd:\n%s\n actual arguments:\n%s\n", expected, actual) + } +} diff --git a/tfexec/providers_schema.go b/tfexec/providers_schema.go new file mode 100644 index 00000000..9c98a7aa --- /dev/null +++ b/tfexec/providers_schema.go @@ -0,0 +1,47 @@ +package tfexec + +import ( + "bytes" + "context" + "encoding/json" + "os/exec" + "strings" + + tfjson "github.com/hashicorp/terraform-json" +) + +func (tf *Terraform) ProvidersSchema(ctx context.Context) (*tfjson.ProviderSchemas, error) { + var ret tfjson.ProviderSchemas + + var errBuf strings.Builder + var outBuf bytes.Buffer + + schemaCmd := tf.ProvidersSchemaCmd(ctx) + + schemaCmd.Stderr = &errBuf + schemaCmd.Stdout = &outBuf + + err := schemaCmd.Run() + if err != nil { + return nil, parseError(errBuf.String()) + } + + err = json.Unmarshal(outBuf.Bytes(), &ret) + if err != nil { + return nil, err + } + + err = ret.Validate() + if err != nil { + return nil, err + } + + return &ret, nil +} + +func (tf *Terraform) ProvidersSchemaCmd(ctx context.Context, args ...string) *exec.Cmd { + allArgs := []string{"providers", "schema", "-json", "-no-color"} + allArgs = append(allArgs, args...) + + return tf.buildTerraformCmd(ctx, allArgs...) +} diff --git a/tfexec/providers_schema_test.go b/tfexec/providers_schema_test.go new file mode 100644 index 00000000..71f97d2f --- /dev/null +++ b/tfexec/providers_schema_test.go @@ -0,0 +1,29 @@ +package tfexec + +import ( + "context" + "os" + "strings" + "testing" +) + +func TestProvidersSchemaCmd(t *testing.T) { + td := testTempDir(t) + defer os.RemoveAll(td) + + tf, err := NewTerraform(td, tfPath) + if err != nil { + t.Fatal(err) + } + + // defaults + schemaCmd := tf.ProvidersSchemaCmd(context.Background()) + + actual := strings.TrimPrefix(cmdString(schemaCmd), schemaCmd.Path+" ") + + expected := "providers schema -json -no-color" + + if actual != expected { + t.Fatalf("expected default arguments of ProvidersSchemaCmd:\n%s\n actual arguments:\n%s\n", expected, actual) + } +} diff --git a/tfexec/show.go b/tfexec/show.go new file mode 100644 index 00000000..a4da9ae0 --- /dev/null +++ b/tfexec/show.go @@ -0,0 +1,47 @@ +package tfexec + +import ( + "bytes" + "context" + "encoding/json" + "os/exec" + "strings" + + tfjson "github.com/hashicorp/terraform-json" +) + +func (tf *Terraform) StateShow(ctx context.Context) (*tfjson.State, error) { + var ret tfjson.State + + var errBuf strings.Builder + var outBuf bytes.Buffer + + showCmd := tf.StateShowCmd(ctx) + + showCmd.Stderr = &errBuf + showCmd.Stdout = &outBuf + + err := showCmd.Run() + if err != nil { + return nil, parseError(errBuf.String()) + } + + err = json.Unmarshal(outBuf.Bytes(), &ret) + if err != nil { + return nil, err + } + + err = ret.Validate() + if err != nil { + return nil, err + } + + return &ret, nil +} + +func (tf *Terraform) StateShowCmd(ctx context.Context, args ...string) *exec.Cmd { + allArgs := []string{"show", "-json", "-no-color"} + allArgs = append(allArgs, args...) + + return tf.buildTerraformCmd(ctx, allArgs...) +} diff --git a/tfexec/show_test.go b/tfexec/show_test.go new file mode 100644 index 00000000..1d7fe691 --- /dev/null +++ b/tfexec/show_test.go @@ -0,0 +1,110 @@ +package tfexec + +import ( + "context" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/davecgh/go-spew/spew" + tfjson "github.com/hashicorp/terraform-json" +) + +func TestStateShow(t *testing.T) { + td := testTempDir(t) + defer os.RemoveAll(td) + + tf, err := NewTerraform(td, tfPath) + if err != nil { + t.Fatal(err) + } + + // copy state and config files into test dir + err = copyFile(filepath.Join(testFixtureDir, testTerraformStateFileName), filepath.Join(td, testTerraformStateFileName)) + if err != nil { + t.Fatalf("error copying state file into test dir: %s", err) + } + err = copyFile(filepath.Join(testFixtureDir, testConfigFileName), filepath.Join(td, testConfigFileName)) + if err != nil { + t.Fatalf("error copying config file into test dir: %s", err) + } + + expected := tfjson.State{ + FormatVersion: "0.1", + TerraformVersion: "0.12.24", + Values: &tfjson.StateValues{ + RootModule: &tfjson.StateModule{ + Resources: []*tfjson.StateResource{{ + Address: "null_resource.foo", + AttributeValues: map[string]interface{}{ + "id": "5510719323588825107", + "triggers": nil, + }, + Mode: tfjson.ManagedResourceMode, + Type: "null_resource", + Name: "foo", + ProviderName: "null", + }}, + }, + }, + } + + err = tf.Init(context.Background()) + if err != nil { + t.Fatalf("error running Init in test directory: %s", err) + } + + actual, err := tf.StateShow(context.Background()) + if err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(actual, &expected) { + t.Fatalf("actual: %s\nexpected: %s", spew.Sdump(actual), spew.Sdump(expected)) + } +} + +func TestShow_errInitRequired(t *testing.T) { + td := testTempDir(t) + defer os.RemoveAll(td) + + tf, err := NewTerraform(td, tfPath) + if err != nil { + t.Fatal(err) + } + + err = copyFile(filepath.Join(testFixtureDir, testTerraformStateFileName), filepath.Join(td, testTerraformStateFileName)) + + _, err = tf.StateShow(context.Background()) + if err == nil { + t.Fatal("expected Show to error, but it did not") + } else { + if _, ok := err.(*ErrNoInit); !ok { + t.Fatalf("expected error %s to be ErrNoInit", err) + } + } + +} + +func TestStateShowCmd(t *testing.T) { + td := testTempDir(t) + defer os.RemoveAll(td) + + tf, err := NewTerraform(td, tfPath) + if err != nil { + t.Fatal(err) + } + + // defaults + showCmd := tf.StateShowCmd(context.Background()) + + actual := strings.TrimPrefix(cmdString(showCmd), showCmd.Path+" ") + + expected := "show -json -no-color" + + if actual != expected { + t.Fatalf("expected default arguments of ShowCmd:\n%s\n actual arguments:\n%s\n", expected, actual) + } +} diff --git a/tfexec/terraform.go b/tfexec/terraform.go index b7374500..043f4977 100644 --- a/tfexec/terraform.go +++ b/tfexec/terraform.go @@ -3,14 +3,11 @@ package tfexec import ( "bytes" "context" - "encoding/json" "fmt" "io/ioutil" "log" "os" "strings" - - tfjson "github.com/hashicorp/terraform-json" ) type Terraform struct { @@ -91,528 +88,3 @@ func (tf *Terraform) version() (string, error) { return outBuf.String(), nil } - -type initConfig struct { - backend bool - backendConfig []string - forceCopy bool - fromModule string - get bool - getPlugins bool - lock bool - lockTimeout string - pluginDir []string - reconfigure bool - upgrade bool - verifyPlugins bool -} - -var defaultInitOptions = initConfig{ - backend: true, - forceCopy: false, - get: true, - getPlugins: true, - lock: true, - lockTimeout: "0s", - reconfigure: false, - upgrade: false, - verifyPlugins: true, -} - -type InitOption interface { - configureInit(*initConfig) -} - -func (opt *BackendOption) configureInit(conf *initConfig) { - conf.backend = opt.backend -} - -func (opt *BackendConfigOption) configureInit(conf *initConfig) { - conf.backendConfig = append(conf.backendConfig, opt.path) -} - -func (opt *FromModuleOption) configureInit(conf *initConfig) { - conf.fromModule = opt.source -} - -func (opt *GetOption) configureInit(conf *initConfig) { - conf.get = opt.get -} - -func (opt *GetPluginsOption) configureInit(conf *initConfig) { - conf.getPlugins = opt.getPlugins -} - -func (opt *LockOption) configureInit(conf *initConfig) { - conf.lock = opt.lock -} - -func (opt *LockTimeoutOption) configureInit(conf *initConfig) { - conf.lockTimeout = opt.timeout -} - -func (opt *PluginDirOption) configureInit(conf *initConfig) { - conf.pluginDir = append(conf.pluginDir, opt.pluginDir) -} - -func (opt *ReconfigureOption) configureInit(conf *initConfig) { - conf.reconfigure = opt.reconfigure -} - -func (opt *UpgradeOption) configureInit(conf *initConfig) { - conf.upgrade = opt.upgrade -} - -func (opt *VerifyPluginsOption) configureInit(conf *initConfig) { - conf.verifyPlugins = opt.verifyPlugins -} - -func (t *Terraform) Init(ctx context.Context, opts ...InitOption) error { - initCmd := t.InitCmd(ctx, opts...) - - var errBuf strings.Builder - initCmd.Stderr = &errBuf - - err := initCmd.Run() - if err != nil { - return parseError(errBuf.String()) - } - - return nil -} - -type applyConfig struct { - backup string - dirOrPlan string - lock bool - - // LockTimeout must be a string with time unit, e.g. '10s' - lockTimeout string - parallelism int - refresh bool - state string - stateOut string - targets []string - - // Vars: each var must be supplied as a single string, e.g. 'foo=bar' - vars []string - varFile string -} - -var defaultApplyOptions = applyConfig{ - lock: true, - parallelism: 10, - refresh: true, -} - -type ApplyOption interface { - configureApply(*applyConfig) -} - -func (opt *ParallelismOption) configureApply(conf *applyConfig) { - conf.parallelism = opt.parallelism -} - -func (opt *BackupOption) configureApply(conf *applyConfig) { - conf.backup = opt.path -} - -func (opt *TargetOption) configureApply(conf *applyConfig) { - conf.targets = append(conf.targets, opt.target) -} - -func (opt *LockTimeoutOption) configureApply(conf *applyConfig) { - conf.lockTimeout = opt.timeout -} - -func (opt *StateOption) configureApply(conf *applyConfig) { - conf.state = opt.path -} - -func (opt *StateOutOption) configureApply(conf *applyConfig) { - conf.stateOut = opt.path -} - -func (opt *VarFileOption) configureApply(conf *applyConfig) { - conf.varFile = opt.path -} - -func (opt *LockOption) configureApply(conf *applyConfig) { - conf.lock = opt.lock -} - -func (opt *RefreshOption) configureApply(conf *applyConfig) { - conf.refresh = opt.refresh -} - -func (opt *VarOption) configureApply(conf *applyConfig) { - conf.vars = append(conf.vars, opt.assignment) -} - -func (opt *DirOrPlanOption) configureApply(conf *applyConfig) { - conf.dirOrPlan = opt.path -} - -func (tf *Terraform) Apply(ctx context.Context, opts ...ApplyOption) error { - applyCmd := tf.ApplyCmd(ctx, opts...) - - var errBuf strings.Builder - applyCmd.Stderr = &errBuf - - err := applyCmd.Run() - if err != nil { - return parseError(errBuf.String()) - } - - return nil -} - -type destroyConfig struct { - backup string - lock bool - - // LockTimeout must be a string with time unit, e.g. '10s' - lockTimeout string - parallelism int - refresh bool - state string - stateOut string - targets []string - - // Vars: each var must be supplied as a single string, e.g. 'foo=bar' - vars []string - varFile string -} - -var defaultDestroyOptions = destroyConfig{ - lock: true, - lockTimeout: "0s", - parallelism: 10, - refresh: true, -} - -type DestroyOption interface { - configureDestroy(*destroyConfig) -} - -func (opt *ParallelismOption) configureDestroy(conf *destroyConfig) { - conf.parallelism = opt.parallelism -} - -func (opt *BackupOption) configureDestroy(conf *destroyConfig) { - conf.backup = opt.path -} - -func (opt *TargetOption) configureDestroy(conf *destroyConfig) { - conf.targets = append(conf.targets, opt.target) -} - -func (opt *LockTimeoutOption) configureDestroy(conf *destroyConfig) { - conf.lockTimeout = opt.timeout -} - -func (opt *StateOption) configureDestroy(conf *destroyConfig) { - conf.state = opt.path -} - -func (opt *StateOutOption) configureDestroy(conf *destroyConfig) { - conf.stateOut = opt.path -} - -func (opt *VarFileOption) configureDestroy(conf *destroyConfig) { - conf.varFile = opt.path -} - -func (opt *LockOption) configureDestroy(conf *destroyConfig) { - conf.lock = opt.lock -} - -func (opt *RefreshOption) configureDestroy(conf *destroyConfig) { - conf.refresh = opt.refresh -} - -func (opt *VarOption) configureDestroy(conf *destroyConfig) { - conf.vars = append(conf.vars, opt.assignment) -} - -func (tf *Terraform) Destroy(ctx context.Context, opts ...DestroyOption) error { - destroyCmd := tf.DestroyCmd(ctx, opts...) - - var errBuf strings.Builder - destroyCmd.Stderr = &errBuf - - err := destroyCmd.Run() - if err != nil { - return parseError(errBuf.String()) - } - - return nil -} - -type planConfig struct { - destroy bool - lock bool - lockTimeout string - out string - parallelism int - refresh bool - state string - targets []string - vars []string - varFile string -} - -var defaultPlanOptions = planConfig{ - destroy: false, - lock: true, - lockTimeout: "0s", - parallelism: 10, - refresh: true, -} - -type PlanOption interface { - configurePlan(*planConfig) -} - -func (opt *VarFileOption) configurePlan(conf *planConfig) { - conf.varFile = opt.path -} - -func (opt *VarOption) configurePlan(conf *planConfig) { - conf.vars = append(conf.vars, opt.assignment) -} - -func (opt *TargetOption) configurePlan(conf *planConfig) { - conf.targets = append(conf.targets, opt.target) -} - -func (opt *StateOption) configurePlan(conf *planConfig) { - conf.state = opt.path -} - -func (opt *RefreshOption) configurePlan(conf *planConfig) { - conf.refresh = opt.refresh -} - -func (opt *ParallelismOption) configurePlan(conf *planConfig) { - conf.parallelism = opt.parallelism -} - -func (opt *OutOption) configurePlan(conf *planConfig) { - conf.out = opt.path -} - -func (opt *LockTimeoutOption) configurePlan(conf *planConfig) { - conf.lockTimeout = opt.timeout -} - -func (opt *LockOption) configurePlan(conf *planConfig) { - conf.lock = opt.lock -} - -func (opt *DestroyFlagOption) configurePlan(conf *planConfig) { - conf.destroy = opt.destroy -} - -func (tf *Terraform) Plan(ctx context.Context, opts ...PlanOption) error { - planCmd := tf.PlanCmd(ctx, opts...) - - var errBuf strings.Builder - planCmd.Stderr = &errBuf - - err := planCmd.Run() - if err != nil { - return parseError(errBuf.String()) - } - - return nil -} - -type importConfig struct { - addr string - id string - backup string - config string - allowMissingConfig bool - lock bool - lockTimeout string - state string - stateOut string - vars []string - varFile string -} - -var defaultImportOptions = importConfig{ - allowMissingConfig: false, - lock: true, - lockTimeout: "0s", -} - -type ImportOption interface { - configureImport(*importConfig) -} - -func (opt *AddrOption) configureImport(conf *importConfig) { - conf.addr = opt.addr -} - -func (opt *IdOption) configureImport(conf *importConfig) { - conf.id = opt.id -} - -func (opt *BackupOption) configureImport(conf *importConfig) { - conf.backup = opt.path -} - -func (opt *ConfigOption) configureImport(conf *importConfig) { - conf.config = opt.path -} - -func (opt *AllowMissingConfigOption) configureImport(conf *importConfig) { - conf.allowMissingConfig = opt.allowMissingConfig -} - -func (opt *LockOption) configureImport(conf *importConfig) { - conf.lock = opt.lock -} - -func (opt *LockTimeoutOption) configureImport(conf *importConfig) { - conf.lockTimeout = opt.timeout -} - -func (opt *StateOption) configureImport(conf *importConfig) { - conf.state = opt.path -} - -func (opt *StateOutOption) configureImport(conf *importConfig) { - conf.stateOut = opt.path -} - -func (opt *VarOption) configureImport(conf *importConfig) { - conf.vars = append(conf.vars, opt.assignment) -} - -func (opt *VarFileOption) configureImport(conf *importConfig) { - conf.varFile = opt.path -} - -func (t *Terraform) Import(ctx context.Context, opts ...ImportOption) error { - importCmd := t.ImportCmd(ctx, opts...) - - var errBuf strings.Builder - importCmd.Stderr = &errBuf - - err := importCmd.Run() - if err != nil { - return parseError(errBuf.String()) - } - - return nil -} - -type outputConfig struct { - state string - json bool -} - -var defaultOutputOptions = outputConfig{} - -type OutputOption interface { - configureOutput(*outputConfig) -} - -func (opt *StateOption) configureOutput(conf *outputConfig) { - conf.state = opt.path -} - -// OutputMeta represents the JSON output of 'terraform output -json', -// which resembles state format version 3 due to a historical accident. -// Please see hashicorp/terraform/command/output.go. -// TODO KEM: Should this type be in terraform-json? -type OutputMeta struct { - Sensitive bool `json:"sensitive"` - Type json.RawMessage `json:"type"` - Value json.RawMessage `json:"value"` -} - -func (tf *Terraform) Output(ctx context.Context, opts ...OutputOption) (map[string]OutputMeta, error) { - outputCmd := tf.OutputCmd(ctx, opts...) - - var errBuf strings.Builder - var outBuf bytes.Buffer - - outputCmd.Stderr = &errBuf - outputCmd.Stdout = &outBuf - - outputs := map[string]OutputMeta{} - - err := outputCmd.Run() - if err != nil { - return nil, parseError(err.Error()) - } - - err = json.Unmarshal(outBuf.Bytes(), &outputs) - if err != nil { - return nil, err - } - - return outputs, nil -} - -func (tf *Terraform) StateShow(ctx context.Context) (*tfjson.State, error) { - var ret tfjson.State - - var errBuf strings.Builder - var outBuf bytes.Buffer - - showCmd := tf.StateShowCmd(ctx) - - showCmd.Stderr = &errBuf - showCmd.Stdout = &outBuf - - err := showCmd.Run() - if err != nil { - return nil, parseError(errBuf.String()) - } - - err = json.Unmarshal(outBuf.Bytes(), &ret) - if err != nil { - return nil, err - } - - err = ret.Validate() - if err != nil { - return nil, err - } - - return &ret, nil -} - -func (tf *Terraform) ProvidersSchema(ctx context.Context) (*tfjson.ProviderSchemas, error) { - var ret tfjson.ProviderSchemas - - var errBuf strings.Builder - var outBuf bytes.Buffer - - schemaCmd := tf.ProvidersSchemaCmd(ctx) - - schemaCmd.Stderr = &errBuf - schemaCmd.Stdout = &outBuf - - err := schemaCmd.Run() - if err != nil { - return nil, parseError(errBuf.String()) - } - - err = json.Unmarshal(outBuf.Bytes(), &ret) - if err != nil { - return nil, err - } - - err = ret.Validate() - if err != nil { - return nil, err - } - - return &ret, nil -} diff --git a/tfexec/terraform_cmd.go b/tfexec/terraform_cmd.go index 6191bf76..13e0e633 100644 --- a/tfexec/terraform_cmd.go +++ b/tfexec/terraform_cmd.go @@ -2,9 +2,7 @@ package tfexec import ( "context" - "fmt" "os/exec" - "strconv" ) func (tf *Terraform) buildTerraformCmd(ctx context.Context, args ...string) *exec.Cmd { @@ -18,271 +16,3 @@ func (tf *Terraform) buildTerraformCmd(ctx context.Context, args ...string) *exe return cmd } - -func (tf *Terraform) InitCmd(ctx context.Context, opts ...InitOption) *exec.Cmd { - c := defaultInitOptions - - for _, o := range opts { - o.configureInit(&c) - } - - args := []string{"init", "-no-color", "-force-copy", "-input=false"} - - // string opts: only pass if set - if c.fromModule != "" { - args = append(args, "-from-module="+c.fromModule) - } - if c.lockTimeout != "" { - args = append(args, "-lock-timeout="+c.lockTimeout) - } - - // boolean opts: always pass - args = append(args, "-backend="+fmt.Sprint(c.backend)) - args = append(args, "-get="+fmt.Sprint(c.get)) - args = append(args, "-get-plugins="+fmt.Sprint(c.getPlugins)) - args = append(args, "-lock="+fmt.Sprint(c.lock)) - args = append(args, "-upgrade="+fmt.Sprint(c.upgrade)) - args = append(args, "-verify-plugins="+fmt.Sprint(c.verifyPlugins)) - - // unary flags: pass if true - if c.reconfigure { - args = append(args, "-reconfigure") - } - - // string slice opts: split into separate args - if c.backendConfig != nil { - for _, bc := range c.backendConfig { - args = append(args, "-backend-config="+bc) - } - } - if c.pluginDir != nil { - for _, pd := range c.pluginDir { - args = append(args, "-plugin-dir="+pd) - } - } - - return tf.buildTerraformCmd(ctx, args...) -} - -func (tf *Terraform) ApplyCmd(ctx context.Context, opts ...ApplyOption) *exec.Cmd { - c := defaultApplyOptions - - for _, o := range opts { - o.configureApply(&c) - } - - args := []string{"apply", "-no-color", "-auto-approve", "-input=false"} - - // string opts: only pass if set - if c.backup != "" { - args = append(args, "-backup="+c.backup) - } - if c.lockTimeout != "" { - args = append(args, "-lock-timeout="+c.lockTimeout) - } - if c.state != "" { - args = append(args, "-state="+c.state) - } - if c.stateOut != "" { - args = append(args, "-state-out="+c.stateOut) - } - if c.varFile != "" { - args = append(args, "-var-file="+c.varFile) - } - - // boolean and numerical opts: always pass - args = append(args, "-lock="+strconv.FormatBool(c.lock)) - args = append(args, "-parallelism="+fmt.Sprint(c.parallelism)) - args = append(args, "-refresh="+strconv.FormatBool(c.refresh)) - - // string slice opts: split into separate args - if c.targets != nil { - for _, ta := range c.targets { - args = append(args, "-target="+ta) - } - } - if c.vars != nil { - for _, v := range c.vars { - args = append(args, "-var '"+v+"'") - } - } - - // string argument: pass if set - if c.dirOrPlan != "" { - args = append(args, c.dirOrPlan) - } - - return tf.buildTerraformCmd(ctx, args...) -} - -func (tf *Terraform) DestroyCmd(ctx context.Context, opts ...DestroyOption) *exec.Cmd { - c := defaultDestroyOptions - - for _, o := range opts { - o.configureDestroy(&c) - } - - args := []string{"destroy", "-no-color", "-auto-approve"} - - // string opts: only pass if set - if c.backup != "" { - args = append(args, "-backup="+c.backup) - } - if c.lockTimeout != "" { - args = append(args, "-lock-timeout="+c.lockTimeout) - } - if c.state != "" { - args = append(args, "-state="+c.state) - } - if c.stateOut != "" { - args = append(args, "-state-out="+c.stateOut) - } - if c.varFile != "" { - args = append(args, "-var-file="+c.varFile) - } - - // boolean and numerical opts: always pass - args = append(args, "-lock="+strconv.FormatBool(c.lock)) - args = append(args, "-parallelism="+fmt.Sprint(c.parallelism)) - args = append(args, "-refresh="+strconv.FormatBool(c.refresh)) - - // string slice opts: split into separate args - if c.targets != nil { - for _, ta := range c.targets { - args = append(args, "-target="+ta) - } - } - if c.vars != nil { - for _, v := range c.vars { - args = append(args, "-var '"+v+"'") - } - } - - return tf.buildTerraformCmd(ctx, args...) -} - -func (tf *Terraform) PlanCmd(ctx context.Context, opts ...PlanOption) *exec.Cmd { - c := defaultPlanOptions - - for _, o := range opts { - o.configurePlan(&c) - } - - args := []string{"plan", "-no-color", "-input=false"} - - // string opts: only pass if set - if c.lockTimeout != "" { - args = append(args, "-lock-timeout="+c.lockTimeout) - } - if c.out != "" { - args = append(args, "-out="+c.out) - } - if c.state != "" { - args = append(args, "-state="+c.state) - } - if c.varFile != "" { - args = append(args, "-var-file="+c.varFile) - } - - // boolean and numerical opts: always pass - args = append(args, "-lock="+strconv.FormatBool(c.lock)) - args = append(args, "-parallelism="+fmt.Sprint(c.parallelism)) - args = append(args, "-refresh="+strconv.FormatBool(c.refresh)) - - // unary flags: pass if true - if c.destroy { - args = append(args, "-destroy") - } - - // string slice opts: split into separate args - if c.targets != nil { - for _, ta := range c.targets { - args = append(args, "-target="+ta) - } - } - if c.vars != nil { - for _, v := range c.vars { - args = append(args, "-var '"+v+"'") - } - } - - return tf.buildTerraformCmd(ctx, args...) -} - -func (tf *Terraform) ImportCmd(ctx context.Context, opts ...ImportOption) *exec.Cmd { - c := defaultImportOptions - - for _, o := range opts { - o.configureImport(&c) - } - - args := []string{"import", "-no-color", "-input=false"} - - // string opts: only pass if set - if c.backup != "" { - args = append(args, "-backup="+c.backup) - } - if c.config != "" { - args = append(args, "-config"+c.config) - } - if c.lockTimeout != "" { - args = append(args, "-lock-timeout="+c.lockTimeout) - } - if c.state != "" { - args = append(args, "-state="+c.state) - } - if c.stateOut != "" { - args = append(args, "-state-out="+c.stateOut) - } - if c.varFile != "" { - args = append(args, "-var-file="+c.varFile) - } - - // boolean and numerical opts: always pass - args = append(args, "-lock="+strconv.FormatBool(c.lock)) - - // unary flags: pass if true - if c.allowMissingConfig { - args = append(args, "-allow-missing-config") - } - - // string slice opts: split into separate args - if c.vars != nil { - for _, v := range c.vars { - args = append(args, "-var '"+v+"'") - } - } - - return tf.buildTerraformCmd(ctx, args...) -} - -func (tf *Terraform) OutputCmd(ctx context.Context, opts ...OutputOption) *exec.Cmd { - c := defaultOutputOptions - - for _, o := range opts { - o.configureOutput(&c) - } - - args := []string{"output", "-no-color", "-json"} - - // string opts: only pass if set - if c.state != "" { - args = append(args, "-state="+c.state) - } - - return tf.buildTerraformCmd(ctx, args...) -} - -func (tf *Terraform) StateShowCmd(ctx context.Context, args ...string) *exec.Cmd { - allArgs := []string{"show", "-json", "-no-color"} - allArgs = append(allArgs, args...) - - return tf.buildTerraformCmd(ctx, allArgs...) -} - -func (tf *Terraform) ProvidersSchemaCmd(ctx context.Context, args ...string) *exec.Cmd { - allArgs := []string{"providers", "schema", "-json", "-no-color"} - allArgs = append(allArgs, args...) - - return tf.buildTerraformCmd(ctx, allArgs...) -} diff --git a/tfexec/terraform_test.go b/tfexec/terraform_test.go index cb6f65cd..45fc2e23 100644 --- a/tfexec/terraform_test.go +++ b/tfexec/terraform_test.go @@ -5,14 +5,9 @@ import ( "io" "io/ioutil" "os" - "path/filepath" "reflect" - "strings" "testing" - "github.com/davecgh/go-spew/spew" - tfjson "github.com/hashicorp/terraform-json" - "github.com/hashicorp/terraform-exec/tfinstall" ) @@ -77,339 +72,6 @@ func TestCheckpointDisablePropagation(t *testing.T) { } } -func TestInitCmd(t *testing.T) { - td := testTempDir(t) - defer os.RemoveAll(td) - - tf, err := NewTerraform(td, tfPath) - if err != nil { - t.Fatal(err) - } - - // defaults - initCmd := tf.InitCmd(context.Background()) - - actual := strings.TrimPrefix(cmdString(initCmd), initCmd.Path+" ") - - expected := "init -no-color -force-copy -input=false -lock-timeout=0s -backend=true -get=true -get-plugins=true -lock=true -upgrade=false -verify-plugins=true" - - if actual != expected { - t.Fatalf("expected default arguments of InitCmd:\n%s\n actual arguments:\n%s\n", expected, actual) - } - - // override all defaults - initCmd = tf.InitCmd(context.Background(), Backend(false), BackendConfig("confpath1"), BackendConfig("confpath2"), FromModule("testsource"), Get(false), GetPlugins(false), Lock(false), LockTimeout("999s"), PluginDir("testdir1"), PluginDir("testdir2"), Reconfigure(true), Upgrade(true), VerifyPlugins(false)) - - actual = strings.TrimPrefix(cmdString(initCmd), initCmd.Path+" ") - - expected = "init -no-color -force-copy -input=false -from-module=testsource -lock-timeout=999s -backend=false -get=false -get-plugins=false -lock=false -upgrade=true -verify-plugins=false -reconfigure -backend-config=confpath1 -backend-config=confpath2 -plugin-dir=testdir1 -plugin-dir=testdir2" - - if actual != expected { - t.Fatalf("expected arguments of InitCmd:\n%s\n actual arguments:\n%s\n", expected, actual) - } -} - -func TestPlanCmd(t *testing.T) { - td := testTempDir(t) - defer os.RemoveAll(td) - - tf, err := NewTerraform(td, tfPath) - if err != nil { - t.Fatal(err) - } - - // defaults - planCmd := tf.PlanCmd(context.Background()) - - actual := strings.TrimPrefix(cmdString(planCmd), planCmd.Path+" ") - - expected := "plan -no-color -input=false -lock-timeout=0s -lock=true -parallelism=10 -refresh=true" - - if actual != expected { - t.Fatalf("expected default arguments of PlanCmd:\n%s\n actual arguments:\n%s\n", expected, actual) - } - - // override all defaults - planCmd = tf.PlanCmd(context.Background(), Destroy(true), Lock(false), LockTimeout("22s"), Out("whale"), Parallelism(42), Refresh(false), State("marvin"), Target("zaphod"), Target("beeblebrox"), Var("android=paranoid"), Var("brain_size=planet"), VarFile("trillian")) - - actual = strings.TrimPrefix(cmdString(planCmd), planCmd.Path+" ") - - expected = "plan -no-color -input=false -lock-timeout=22s -out=whale -state=marvin -var-file=trillian -lock=false -parallelism=42 -refresh=false -destroy -target=zaphod -target=beeblebrox -var 'android=paranoid' -var 'brain_size=planet'" - - if actual != expected { - t.Fatalf("expected arguments of PlanCmd:\n%s\n actual arguments:\n%s\n", expected, actual) - } -} - -func TestApplyCmd(t *testing.T) { - td := testTempDir(t) - defer os.RemoveAll(td) - - tf, err := NewTerraform(td, tfPath) - if err != nil { - t.Fatal(err) - } - - applyCmd := tf.ApplyCmd(context.Background(), Backup("testbackup"), LockTimeout("200s"), State("teststate"), StateOut("teststateout"), VarFile("testvarfile"), Lock(false), Parallelism(99), Refresh(false), Target("target1"), Target("target2"), Var("var1=foo"), Var("var2=bar"), DirOrPlan("testfile")) - - actual := strings.TrimPrefix(cmdString(applyCmd), applyCmd.Path+" ") - - expected := "apply -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' testfile" - - if actual != expected { - t.Fatalf("expected arguments of ApplyCmd:\n%s\n actual arguments:\n%s\n", expected, actual) - } -} - -func TestDestroyCmd(t *testing.T) { - td := testTempDir(t) - defer os.RemoveAll(td) - - tf, err := NewTerraform(td, tfPath) - if err != nil { - t.Fatal(err) - } - - // defaults - destroyCmd := tf.DestroyCmd(context.Background()) - - actual := strings.TrimPrefix(cmdString(destroyCmd), destroyCmd.Path+" ") - - expected := "destroy -no-color -auto-approve -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) - } - - // override all defaults - destroyCmd = tf.DestroyCmd(context.Background(), Backup("testbackup"), LockTimeout("200s"), State("teststate"), StateOut("teststateout"), VarFile("testvarfile"), Lock(false), Parallelism(99), Refresh(false), Target("target1"), Target("target2"), Var("var1=foo"), Var("var2=bar")) - - 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'" - - if actual != expected { - t.Fatalf("expected arguments of DestroyCmd:\n%s\n actual arguments:\n%s\n", expected, actual) - } -} - -func TestImportCmd(t *testing.T) { - td := testTempDir(t) - defer os.RemoveAll(td) - - tf, err := NewTerraform(td, tfPath) - if err != nil { - t.Fatal(err) - } - - // defaults - importCmd := tf.ImportCmd(context.Background()) - - actual := strings.TrimPrefix(cmdString(importCmd), importCmd.Path+" ") - - expected := "import -no-color -input=false -lock-timeout=0s -lock=true" - - if actual != expected { - t.Fatalf("expected default arguments of ImportCmd:\n%s\n actual arguments:\n%s\n", expected, actual) - } - - // override all defaults - importCmd = tf.ImportCmd(context.Background(), - Backup("testbackup"), - LockTimeout("200s"), - State("teststate"), - StateOut("teststateout"), - VarFile("testvarfile"), - Lock(false), - Var("var1=foo"), - Var("var2=bar"), - AllowMissingConfig(true)) - - actual = strings.TrimPrefix(cmdString(importCmd), importCmd.Path+" ") - - expected = "import -no-color -input=false -backup=testbackup -lock-timeout=200s -state=teststate -state-out=teststateout -var-file=testvarfile -lock=false -allow-missing-config -var 'var1=foo' -var 'var2=bar'" - - if actual != expected { - t.Fatalf("expected arguments of ImportCmd:\n%s\n actual arguments:\n%s\n", expected, actual) - } -} - -func TestOutputCmd(t *testing.T) { - td := testTempDir(t) - defer os.RemoveAll(td) - - tf, err := NewTerraform(td, tfPath) - if err != nil { - t.Fatal(err) - } - - // defaults - outputCmd := tf.OutputCmd(context.Background()) - - actual := strings.TrimPrefix(cmdString(outputCmd), outputCmd.Path+" ") - - expected := "output -no-color -json" - - if actual != expected { - t.Fatalf("expected default arguments of OutputCmd:\n%s\n actual arguments:\n%s\n", expected, actual) - } - - // override all defaults - outputCmd = tf.OutputCmd(context.Background(), - State("teststate")) - - actual = strings.TrimPrefix(cmdString(outputCmd), outputCmd.Path+" ") - - expected = "output -no-color -json -state=teststate" - - if actual != expected { - t.Fatalf("expected arguments of ImportCmd:\n%s\n actual arguments:\n%s\n", expected, actual) - } -} - -func TestStateShowCmd(t *testing.T) { - td := testTempDir(t) - defer os.RemoveAll(td) - - tf, err := NewTerraform(td, tfPath) - if err != nil { - t.Fatal(err) - } - - // defaults - showCmd := tf.StateShowCmd(context.Background()) - - actual := strings.TrimPrefix(cmdString(showCmd), showCmd.Path+" ") - - expected := "show -json -no-color" - - if actual != expected { - t.Fatalf("expected default arguments of ShowCmd:\n%s\n actual arguments:\n%s\n", expected, actual) - } -} - -func TestProvidersSchemaCmd(t *testing.T) { - td := testTempDir(t) - defer os.RemoveAll(td) - - tf, err := NewTerraform(td, tfPath) - if err != nil { - t.Fatal(err) - } - - // defaults - schemaCmd := tf.ProvidersSchemaCmd(context.Background()) - - actual := strings.TrimPrefix(cmdString(schemaCmd), schemaCmd.Path+" ") - - expected := "providers schema -json -no-color" - - if actual != expected { - t.Fatalf("expected default arguments of ProvidersSchemaCmd:\n%s\n actual arguments:\n%s\n", expected, actual) - } -} - -func TestStateShow(t *testing.T) { - td := testTempDir(t) - defer os.RemoveAll(td) - - tf, err := NewTerraform(td, tfPath) - if err != nil { - t.Fatal(err) - } - - // copy state and config files into test dir - err = copyFile(filepath.Join(testFixtureDir, testTerraformStateFileName), filepath.Join(td, testTerraformStateFileName)) - if err != nil { - t.Fatalf("error copying state file into test dir: %s", err) - } - err = copyFile(filepath.Join(testFixtureDir, testConfigFileName), filepath.Join(td, testConfigFileName)) - if err != nil { - t.Fatalf("error copying config file into test dir: %s", err) - } - - expected := tfjson.State{ - FormatVersion: "0.1", - TerraformVersion: "0.12.24", - Values: &tfjson.StateValues{ - RootModule: &tfjson.StateModule{ - Resources: []*tfjson.StateResource{{ - Address: "null_resource.foo", - AttributeValues: map[string]interface{}{ - "id": "5510719323588825107", - "triggers": nil, - }, - Mode: tfjson.ManagedResourceMode, - Type: "null_resource", - Name: "foo", - ProviderName: "null", - }}, - }, - }, - } - - err = tf.Init(context.Background()) - if err != nil { - t.Fatalf("error running Init in test directory: %s", err) - } - - actual, err := tf.StateShow(context.Background()) - if err != nil { - t.Fatal(err) - } - - if !reflect.DeepEqual(actual, &expected) { - t.Fatalf("actual: %s\nexpected: %s", spew.Sdump(actual), spew.Sdump(expected)) - } -} - -func TestShow_errInitRequired(t *testing.T) { - td := testTempDir(t) - defer os.RemoveAll(td) - - tf, err := NewTerraform(td, tfPath) - if err != nil { - t.Fatal(err) - } - - err = copyFile(filepath.Join(testFixtureDir, testTerraformStateFileName), filepath.Join(td, testTerraformStateFileName)) - - _, err = tf.StateShow(context.Background()) - if err == nil { - t.Fatal("expected Show to error, but it did not") - } else { - if _, ok := err.(*ErrNoInit); !ok { - t.Fatalf("expected error %s to be ErrNoInit", err) - } - } - -} - -func TestApply(t *testing.T) { - td := testTempDir(t) - defer os.RemoveAll(td) - - tf, err := NewTerraform(td, tfPath) - if err != nil { - t.Fatal(err) - } - - err = copyFile(filepath.Join(testFixtureDir, testConfigFileName), filepath.Join(td, testConfigFileName)) - if err != nil { - t.Fatalf("error copying config file into test dir: %s", err) - } - - err = tf.Init(context.Background()) - if err != nil { - t.Fatalf("error running Init in test directory: %s", err) - } - - err = tf.Apply(context.Background()) - if err != nil { - t.Fatalf("error running Apply: %s", err) - } -} - func testTempDir(t *testing.T) string { d, err := ioutil.TempDir("", "tf") if err != nil {