From 397e1b3132366146f3391315e383e1c9f7c0006f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 18 Jan 2017 20:47:56 -0800 Subject: [PATCH] backend/local The local backend implementation is an implementation of backend.Enhanced that recreates all the behavior of the CLI but through the backend interface. --- backend/local/backend.go | 211 +++++++++++++++ backend/local/backend_apply.go | 149 +++++++++++ backend/local/backend_apply_test.go | 131 ++++++++++ backend/local/backend_local.go | 85 ++++++ backend/local/backend_plan.go | 157 +++++++++++ backend/local/backend_plan_test.go | 183 +++++++++++++ backend/local/backend_refresh.go | 69 +++++ backend/local/backend_refresh_test.go | 144 +++++++++++ backend/local/backend_test.go | 35 +++ backend/local/counthookaction_string.go | 16 ++ backend/local/hook_count.go | 111 ++++++++ backend/local/hook_count_action.go | 11 + backend/local/hook_count_test.go | 243 ++++++++++++++++++ backend/local/hook_state.go | 33 +++ backend/local/hook_state_test.go | 29 +++ backend/local/local_test.go | 25 ++ .../local/test-fixtures/apply-error/main.tf | 7 + backend/local/test-fixtures/apply/main.tf | 3 + backend/local/test-fixtures/plan/main.tf | 9 + .../test-fixtures/refresh-var-unset/main.tf | 9 + backend/local/test-fixtures/refresh/main.tf | 3 + backend/local/testing.go | 66 +++++ 22 files changed, 1729 insertions(+) create mode 100644 backend/local/backend.go create mode 100644 backend/local/backend_apply.go create mode 100644 backend/local/backend_apply_test.go create mode 100644 backend/local/backend_local.go create mode 100644 backend/local/backend_plan.go create mode 100644 backend/local/backend_plan_test.go create mode 100644 backend/local/backend_refresh.go create mode 100644 backend/local/backend_refresh_test.go create mode 100644 backend/local/backend_test.go create mode 100644 backend/local/counthookaction_string.go create mode 100644 backend/local/hook_count.go create mode 100644 backend/local/hook_count_action.go create mode 100644 backend/local/hook_count_test.go create mode 100644 backend/local/hook_state.go create mode 100644 backend/local/hook_state_test.go create mode 100644 backend/local/local_test.go create mode 100644 backend/local/test-fixtures/apply-error/main.tf create mode 100644 backend/local/test-fixtures/apply/main.tf create mode 100644 backend/local/test-fixtures/plan/main.tf create mode 100644 backend/local/test-fixtures/refresh-var-unset/main.tf create mode 100644 backend/local/test-fixtures/refresh/main.tf create mode 100644 backend/local/testing.go diff --git a/backend/local/backend.go b/backend/local/backend.go new file mode 100644 index 000000000..7e6aa9c0a --- /dev/null +++ b/backend/local/backend.go @@ -0,0 +1,211 @@ +package local + +import ( + "context" + "fmt" + "sync" + + "github.com/hashicorp/errwrap" + "github.com/hashicorp/terraform/backend" + "github.com/hashicorp/terraform/helper/schema" + "github.com/hashicorp/terraform/state" + "github.com/hashicorp/terraform/terraform" + "github.com/mitchellh/cli" + "github.com/mitchellh/colorstring" +) + +// Local is an implementation of EnhancedBackend that performs all operations +// locally. This is the "default" backend and implements normal Terraform +// behavior as it is well known. +type Local struct { + // CLI and Colorize control the CLI output. If CLI is nil then no CLI + // output will be done. If CLIColor is nil then no coloring will be done. + CLI cli.Ui + CLIColor *colorstring.Colorize + + // StatePath is the local path where state is read from. + // + // StateOutPath is the local path where the state will be written. + // If this is empty, it will default to StatePath. + // + // StateBackupPath is the local path where a backup file will be written. + // If this is empty, no backup will be taken. + StatePath string + StateOutPath string + StateBackupPath string + + // ContextOpts are the base context options to set when initializing a + // Terraform context. Many of these will be overridden or merged by + // Operation. See Operation for more details. + ContextOpts *terraform.ContextOpts + + // OpInput will ask for necessary input prior to performing any operations. + // + // OpValidation will perform validation prior to running an operation. The + // variable naming doesn't match the style of others since we have a func + // Validate. + OpInput bool + OpValidation bool + + // Backend, if non-nil, will use this backend for non-enhanced behavior. + // This allows local behavior with remote state storage. It is a way to + // "upgrade" a non-enhanced backend to an enhanced backend with typical + // behavior. + // + // If this is nil, local performs normal state loading and storage. + Backend backend.Backend + + schema *schema.Backend + opLock sync.Mutex + once sync.Once +} + +func (b *Local) Input( + ui terraform.UIInput, c *terraform.ResourceConfig) (*terraform.ResourceConfig, error) { + b.once.Do(b.init) + + f := b.schema.Input + if b.Backend != nil { + f = b.Backend.Input + } + + return f(ui, c) +} + +func (b *Local) Validate(c *terraform.ResourceConfig) ([]string, []error) { + b.once.Do(b.init) + + f := b.schema.Validate + if b.Backend != nil { + f = b.Backend.Validate + } + + return f(c) +} + +func (b *Local) Configure(c *terraform.ResourceConfig) error { + b.once.Do(b.init) + + f := b.schema.Configure + if b.Backend != nil { + f = b.Backend.Configure + } + + return f(c) +} + +func (b *Local) State() (state.State, error) { + // If we have a backend handling state, defer to that. + if b.Backend != nil { + return b.Backend.State() + } + + // Otherwise, we need to load the state. + var s state.State = &state.LocalState{ + Path: b.StatePath, + PathOut: b.StateOutPath, + } + + // Load the state as a sanity check + if err := s.RefreshState(); err != nil { + return nil, errwrap.Wrapf("Error reading local state: {{err}}", err) + } + + // If we are backing up the state, wrap it + if path := b.StateBackupPath; path != "" { + s = &state.BackupState{ + Real: s, + Path: path, + } + } + + return s, nil +} + +// Operation implements backend.Enhanced +// +// This will initialize an in-memory terraform.Context to perform the +// operation within this process. +// +// The given operation parameter will be merged with the ContextOpts on +// the structure with the following rules. If a rule isn't specified and the +// name conflicts, assume that the field is overwritten if set. +func (b *Local) Operation(ctx context.Context, op *backend.Operation) (*backend.RunningOperation, error) { + // Determine the function to call for our operation + var f func(context.Context, *backend.Operation, *backend.RunningOperation) + switch op.Type { + case backend.OperationTypeRefresh: + f = b.opRefresh + case backend.OperationTypePlan: + f = b.opPlan + case backend.OperationTypeApply: + f = b.opApply + default: + return nil, fmt.Errorf( + "Unsupported operation type: %s\n\n"+ + "This is a bug in Terraform and should be reported. The local backend\n"+ + "is built-in to Terraform and should always support all operations.", + op.Type) + } + + // Lock + b.opLock.Lock() + + // Build our running operation + runningCtx, runningCtxCancel := context.WithCancel(context.Background()) + runningOp := &backend.RunningOperation{Context: runningCtx} + + // Do it + go func() { + defer b.opLock.Unlock() + defer runningCtxCancel() + f(ctx, op, runningOp) + }() + + // Return + return runningOp, nil +} + +// Colorize returns the Colorize structure that can be used for colorizing +// output. This is gauranteed to always return a non-nil value and so is useful +// as a helper to wrap any potentially colored strings. +func (b *Local) Colorize() *colorstring.Colorize { + if b.CLIColor != nil { + return b.CLIColor + } + + return &colorstring.Colorize{ + Colors: colorstring.DefaultColors, + Disable: true, + } +} + +func (b *Local) init() { + b.schema = &schema.Backend{ + Schema: map[string]*schema.Schema{ + "path": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + }, + + ConfigureFunc: b.schemaConfigure, + } +} + +func (b *Local) schemaConfigure(ctx context.Context) error { + d := schema.FromContextBackendConfig(ctx) + + // Set the path if it is set + pathRaw, ok := d.GetOk("path") + if ok { + path := pathRaw.(string) + if path == "" { + return fmt.Errorf("configured path is empty") + } + + b.StatePath = path + } + + return nil +} diff --git a/backend/local/backend_apply.go b/backend/local/backend_apply.go new file mode 100644 index 000000000..4ba1d27be --- /dev/null +++ b/backend/local/backend_apply.go @@ -0,0 +1,149 @@ +package local + +import ( + "context" + "fmt" + "log" + + "github.com/hashicorp/errwrap" + "github.com/hashicorp/go-multierror" + "github.com/hashicorp/terraform/backend" + "github.com/hashicorp/terraform/terraform" +) + +func (b *Local) opApply( + ctx context.Context, + op *backend.Operation, + runningOp *backend.RunningOperation) { + log.Printf("[INFO] backend/local: starting Apply operation") + + // Setup our count hook that keeps track of resource changes + countHook := new(CountHook) + stateHook := new(StateHook) + if b.ContextOpts == nil { + b.ContextOpts = new(terraform.ContextOpts) + } + old := b.ContextOpts.Hooks + defer func() { b.ContextOpts.Hooks = old }() + b.ContextOpts.Hooks = append(b.ContextOpts.Hooks, countHook, stateHook) + + // Get our context + tfCtx, state, err := b.context(op) + if err != nil { + runningOp.Err = err + return + } + + // Setup the state + runningOp.State = tfCtx.State() + + // If we weren't given a plan, then we refresh/plan + if op.Plan == nil { + // If we're refreshing before apply, perform that + if op.PlanRefresh { + log.Printf("[INFO] backend/local: apply calling Refresh") + _, err := tfCtx.Refresh() + if err != nil { + runningOp.Err = errwrap.Wrapf("Error refreshing state: {{err}}", err) + return + } + } + + // Perform the plan + log.Printf("[INFO] backend/local: apply calling Plan") + if _, err := tfCtx.Plan(); err != nil { + runningOp.Err = errwrap.Wrapf("Error running plan: {{err}}", err) + return + } + } + + // Setup our hook for continuous state updates + stateHook.State = state + + // Start the apply in a goroutine so that we can be interrupted. + var applyState *terraform.State + var applyErr error + doneCh := make(chan struct{}) + go func() { + defer close(doneCh) + applyState, applyErr = tfCtx.Apply() + + /* + // Record any shadow errors for later + if err := ctx.ShadowError(); err != nil { + shadowErr = multierror.Append(shadowErr, multierror.Prefix( + err, "apply operation:")) + } + */ + }() + + // Wait for the apply to finish or for us to be interrupted so + // we can handle it properly. + err = nil + select { + case <-ctx.Done(): + if b.CLI != nil { + b.CLI.Output("Interrupt received. Gracefully shutting down...") + } + + // Stop execution + go tfCtx.Stop() + + // Wait for completion still + <-doneCh + case <-doneCh: + } + + // Store the final state + runningOp.State = applyState + + // Persist the state + if err := state.WriteState(applyState); err != nil { + runningOp.Err = fmt.Errorf("Failed to save state: %s", err) + return + } + if err := state.PersistState(); err != nil { + runningOp.Err = fmt.Errorf("Failed to save state: %s", err) + return + } + + if applyErr != nil { + runningOp.Err = fmt.Errorf( + "Error applying plan:\n\n"+ + "%s\n\n"+ + "Terraform does not automatically rollback in the face of errors.\n"+ + "Instead, your Terraform state file has been partially updated with\n"+ + "any resources that successfully completed. Please address the error\n"+ + "above and apply again to incrementally change your infrastructure.", + multierror.Flatten(applyErr)) + return + } + + // If we have a UI, output the results + if b.CLI != nil { + if op.Destroy { + b.CLI.Output(b.Colorize().Color(fmt.Sprintf( + "[reset][bold][green]\n"+ + "Destroy complete! Resources: %d destroyed.", + countHook.Removed))) + } else { + b.CLI.Output(b.Colorize().Color(fmt.Sprintf( + "[reset][bold][green]\n"+ + "Apply complete! Resources: %d added, %d changed, %d destroyed.", + countHook.Added, + countHook.Changed, + countHook.Removed))) + } + + if countHook.Added > 0 || countHook.Changed > 0 { + b.CLI.Output(b.Colorize().Color(fmt.Sprintf( + "[reset]\n"+ + "The state of your infrastructure has been saved to the path\n"+ + "below. This state is required to modify and destroy your\n"+ + "infrastructure, so keep it safe. To inspect the complete state\n"+ + "use the `terraform show` command.\n\n"+ + "State path: %s", + b.StateOutPath))) + } + } +} diff --git a/backend/local/backend_apply_test.go b/backend/local/backend_apply_test.go new file mode 100644 index 000000000..7424faa6b --- /dev/null +++ b/backend/local/backend_apply_test.go @@ -0,0 +1,131 @@ +package local + +import ( + "context" + "fmt" + "sync" + "testing" + + "github.com/hashicorp/terraform/backend" + "github.com/hashicorp/terraform/config/module" + "github.com/hashicorp/terraform/terraform" +) + +func TestLocal_applyBasic(t *testing.T) { + b := TestLocal(t) + p := TestLocalProvider(t, b, "test") + + p.ApplyReturn = &terraform.InstanceState{ID: "yes"} + + mod, modCleanup := module.TestTree(t, "./test-fixtures/apply") + defer modCleanup() + + op := testOperationApply() + op.Module = mod + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("bad: %s", err) + } + <-run.Done() + if run.Err != nil { + t.Fatalf("err: %s", err) + } + + if p.RefreshCalled { + t.Fatal("refresh should not be called") + } + + if !p.DiffCalled { + t.Fatal("diff should be called") + } + + if !p.ApplyCalled { + t.Fatal("apply should be called") + } + + checkState(t, b.StateOutPath, ` +test_instance.foo: + ID = yes + `) +} + +func TestLocal_applyError(t *testing.T) { + b := TestLocal(t) + p := TestLocalProvider(t, b, "test") + + var lock sync.Mutex + errored := false + p.ApplyFn = func( + info *terraform.InstanceInfo, + s *terraform.InstanceState, + d *terraform.InstanceDiff) (*terraform.InstanceState, error) { + lock.Lock() + defer lock.Unlock() + + if !errored && info.Id == "test_instance.bar" { + errored = true + return nil, fmt.Errorf("error") + } + + return &terraform.InstanceState{ID: "foo"}, nil + } + p.DiffFn = func( + *terraform.InstanceInfo, + *terraform.InstanceState, + *terraform.ResourceConfig) (*terraform.InstanceDiff, error) { + return &terraform.InstanceDiff{ + Attributes: map[string]*terraform.ResourceAttrDiff{ + "ami": &terraform.ResourceAttrDiff{ + New: "bar", + }, + }, + }, nil + } + + mod, modCleanup := module.TestTree(t, "./test-fixtures/apply-error") + defer modCleanup() + + op := testOperationApply() + op.Module = mod + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("bad: %s", err) + } + <-run.Done() + if run.Err == nil { + t.Fatal("should error") + } + + checkState(t, b.StateOutPath, ` +test_instance.foo: + ID = foo + `) +} + +func testOperationApply() *backend.Operation { + return &backend.Operation{ + Type: backend.OperationTypeApply, + } +} + +// testApplyState is just a common state that we use for testing refresh. +func testApplyState() *terraform.State { + return &terraform.State{ + Version: 2, + Modules: []*terraform.ModuleState{ + &terraform.ModuleState{ + Path: []string{"root"}, + Resources: map[string]*terraform.ResourceState{ + "test_instance.foo": &terraform.ResourceState{ + Type: "test_instance", + Primary: &terraform.InstanceState{ + ID: "bar", + }, + }, + }, + }, + }, + } +} diff --git a/backend/local/backend_local.go b/backend/local/backend_local.go new file mode 100644 index 000000000..9f5d418d4 --- /dev/null +++ b/backend/local/backend_local.go @@ -0,0 +1,85 @@ +package local + +import ( + "github.com/hashicorp/errwrap" + "github.com/hashicorp/go-multierror" + "github.com/hashicorp/terraform/backend" + "github.com/hashicorp/terraform/state" + "github.com/hashicorp/terraform/terraform" +) + +// backend.Local implementation. +func (b *Local) Context(op *backend.Operation) (*terraform.Context, state.State, error) { + // Make sure the type is invalid. We use this as a way to know not + // to ask for input/validate. + op.Type = backend.OperationTypeInvalid + + return b.context(op) +} + +func (b *Local) context(op *backend.Operation) (*terraform.Context, state.State, error) { + // Get the state. + s, err := b.State() + if err != nil { + return nil, nil, errwrap.Wrapf("Error loading state: {{err}}", err) + } + if err := s.RefreshState(); err != nil { + return nil, nil, errwrap.Wrapf("Error loading state: {{err}}", err) + } + + // Initialize our context options + var opts terraform.ContextOpts + if v := b.ContextOpts; v != nil { + opts = *v + } + + // Copy set options from the operation + opts.Destroy = op.Destroy + opts.Module = op.Module + opts.Targets = op.Targets + opts.UIInput = op.UIIn + if op.Variables != nil { + opts.Variables = op.Variables + } + + // Load our state + opts.State = s.State() + + // Build the context + var tfCtx *terraform.Context + if op.Plan != nil { + tfCtx, err = op.Plan.Context(&opts) + } else { + tfCtx, err = terraform.NewContext(&opts) + } + if err != nil { + return nil, nil, err + } + + // If we have an operation, then we automatically do the input/validate + // here since every option requires this. + if op.Type != backend.OperationTypeInvalid { + // If input asking is enabled, then do that + if op.Plan == nil && b.OpInput { + mode := terraform.InputModeProvider + mode |= terraform.InputModeVar + mode |= terraform.InputModeVarUnset + + if err := tfCtx.Input(mode); err != nil { + return nil, nil, errwrap.Wrapf("Error asking for user input: {{err}}", err) + } + } + + // If validation is enabled, validate + if b.OpValidation { + // We ignore warnings here on purpose. We expect users to be listening + // to the terraform.Hook called after a validation. + _, es := tfCtx.Validate() + if len(es) > 0 { + return nil, nil, multierror.Append(nil, es...) + } + } + } + + return tfCtx, s, nil +} diff --git a/backend/local/backend_plan.go b/backend/local/backend_plan.go new file mode 100644 index 000000000..b4f360d1b --- /dev/null +++ b/backend/local/backend_plan.go @@ -0,0 +1,157 @@ +package local + +import ( + "context" + "fmt" + "log" + "os" + "strings" + + "github.com/hashicorp/errwrap" + "github.com/hashicorp/terraform/backend" + "github.com/hashicorp/terraform/command/format" + "github.com/hashicorp/terraform/terraform" +) + +func (b *Local) opPlan( + ctx context.Context, + op *backend.Operation, + runningOp *backend.RunningOperation) { + log.Printf("[INFO] backend/local: starting Plan operation") + + if b.CLI != nil && op.Plan != nil { + b.CLI.Output(b.Colorize().Color( + "[reset][bold][yellow]" + + "The plan command received a saved plan file as input. This command\n" + + "will output the saved plan. This will not modify the already-existing\n" + + "plan. If you wish to generate a new plan, please pass in a configuration\n" + + "directory as an argument.\n\n")) + } + + // Setup our count hook that keeps track of resource changes + countHook := new(CountHook) + if b.ContextOpts == nil { + b.ContextOpts = new(terraform.ContextOpts) + } + old := b.ContextOpts.Hooks + defer func() { b.ContextOpts.Hooks = old }() + b.ContextOpts.Hooks = append(b.ContextOpts.Hooks, countHook) + + // Get our context + tfCtx, _, err := b.context(op) + if err != nil { + runningOp.Err = err + return + } + + // Setup the state + runningOp.State = tfCtx.State() + + // If we're refreshing before plan, perform that + if op.PlanRefresh { + log.Printf("[INFO] backend/local: plan calling Refresh") + + b.CLI.Output(b.Colorize().Color(strings.TrimSpace(planRefreshing) + "\n")) + _, err := tfCtx.Refresh() + if err != nil { + runningOp.Err = errwrap.Wrapf("Error refreshing state: {{err}}", err) + return + } + } + + // Perform the plan + log.Printf("[INFO] backend/local: plan calling Plan") + plan, err := tfCtx.Plan() + if err != nil { + runningOp.Err = errwrap.Wrapf("Error running plan: {{err}}", err) + return + } + + // Record state + runningOp.PlanEmpty = plan.Diff.Empty() + + // Save the plan to disk + if path := op.PlanOutPath; path != "" { + // Write the backend if we have one + plan.Backend = op.PlanOutBackend + + log.Printf("[INFO] backend/local: writing plan output to: %s", path) + f, err := os.Create(path) + if err == nil { + err = terraform.WritePlan(plan, f) + } + f.Close() + if err != nil { + runningOp.Err = fmt.Errorf("Error writing plan file: %s", err) + return + } + } + + // Perform some output tasks if we have a CLI to output to. + if b.CLI != nil { + if plan.Diff.Empty() { + b.CLI.Output(b.Colorize().Color(strings.TrimSpace(planNoChanges))) + return + } + + if path := op.PlanOutPath; path == "" { + b.CLI.Output(strings.TrimSpace(planHeaderNoOutput) + "\n") + } else { + b.CLI.Output(fmt.Sprintf( + strings.TrimSpace(planHeaderYesOutput)+"\n", + path)) + } + + b.CLI.Output(format.Plan(&format.PlanOpts{ + Plan: plan, + Color: b.Colorize(), + ModuleDepth: -1, + })) + + b.CLI.Output(b.Colorize().Color(fmt.Sprintf( + "[reset][bold]Plan:[reset] "+ + "%d to add, %d to change, %d to destroy.", + countHook.ToAdd+countHook.ToRemoveAndAdd, + countHook.ToChange, + countHook.ToRemove+countHook.ToRemoveAndAdd))) + } +} + +const planHeaderNoOutput = ` +The Terraform execution plan has been generated and is shown below. +Resources are shown in alphabetical order for quick scanning. Green resources +will be created (or destroyed and then created if an existing resource +exists), yellow resources are being changed in-place, and red resources +will be destroyed. Cyan entries are data sources to be read. + +Note: You didn't specify an "-out" parameter to save this plan, so when +"apply" is called, Terraform can't guarantee this is what will execute. +` + +const planHeaderYesOutput = ` +The Terraform execution plan has been generated and is shown below. +Resources are shown in alphabetical order for quick scanning. Green resources +will be created (or destroyed and then created if an existing resource +exists), yellow resources are being changed in-place, and red resources +will be destroyed. Cyan entries are data sources to be read. + +Your plan was also saved to the path below. Call the "apply" subcommand +with this plan file and Terraform will exactly execute this execution +plan. + +Path: %s +` + +const planNoChanges = ` +[reset][bold][green]No changes. Infrastructure is up-to-date.[reset][green] + +This means that Terraform could not detect any differences between your +configuration and real physical resources that exist. As a result, Terraform +doesn't need to do anything. +` + +const planRefreshing = ` +[reset][bold]Refreshing Terraform state in-memory prior to plan...[reset] +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. +` diff --git a/backend/local/backend_plan_test.go b/backend/local/backend_plan_test.go new file mode 100644 index 000000000..20e0420a2 --- /dev/null +++ b/backend/local/backend_plan_test.go @@ -0,0 +1,183 @@ +package local + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/hashicorp/terraform/backend" + "github.com/hashicorp/terraform/config/module" + "github.com/hashicorp/terraform/terraform" +) + +func TestLocal_planBasic(t *testing.T) { + b := TestLocal(t) + p := TestLocalProvider(t, b, "test") + + mod, modCleanup := module.TestTree(t, "./test-fixtures/plan") + defer modCleanup() + + op := testOperationPlan() + op.Module = mod + op.PlanRefresh = true + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("bad: %s", err) + } + <-run.Done() + if run.Err != nil { + t.Fatalf("err: %s", err) + } + + if !p.DiffCalled { + t.Fatal("diff should be called") + } +} + +func TestLocal_planRefreshFalse(t *testing.T) { + b := TestLocal(t) + p := TestLocalProvider(t, b, "test") + terraform.TestStateFile(t, b.StatePath, testPlanState()) + + mod, modCleanup := module.TestTree(t, "./test-fixtures/plan") + defer modCleanup() + + op := testOperationPlan() + op.Module = mod + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("bad: %s", err) + } + <-run.Done() + if run.Err != nil { + t.Fatalf("err: %s", err) + } + + if p.RefreshCalled { + t.Fatal("refresh should not be called") + } + + if !run.PlanEmpty { + t.Fatal("plan should be empty") + } +} + +func TestLocal_planDestroy(t *testing.T) { + b := TestLocal(t) + p := TestLocalProvider(t, b, "test") + terraform.TestStateFile(t, b.StatePath, testPlanState()) + + mod, modCleanup := module.TestTree(t, "./test-fixtures/plan") + defer modCleanup() + + outDir := testTempDir(t) + defer os.RemoveAll(outDir) + planPath := filepath.Join(outDir, "plan.tfplan") + + op := testOperationPlan() + op.Destroy = true + op.PlanRefresh = true + op.Module = mod + op.PlanOutPath = planPath + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("bad: %s", err) + } + <-run.Done() + if run.Err != nil { + t.Fatalf("err: %s", err) + } + + if !p.RefreshCalled { + t.Fatal("refresh should be called") + } + + if run.PlanEmpty { + t.Fatal("plan should not be empty") + } + + plan := testReadPlan(t, planPath) + for _, m := range plan.Diff.Modules { + for _, r := range m.Resources { + if !r.Destroy { + t.Fatalf("bad: %#v", r) + } + } + } +} + +func TestLocal_planOutPathNoChange(t *testing.T) { + b := TestLocal(t) + TestLocalProvider(t, b, "test") + terraform.TestStateFile(t, b.StatePath, testPlanState()) + + mod, modCleanup := module.TestTree(t, "./test-fixtures/plan") + defer modCleanup() + + outDir := testTempDir(t) + defer os.RemoveAll(outDir) + planPath := filepath.Join(outDir, "plan.tfplan") + + op := testOperationPlan() + op.Module = mod + op.PlanOutPath = planPath + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("bad: %s", err) + } + <-run.Done() + if run.Err != nil { + t.Fatalf("err: %s", err) + } + + plan := testReadPlan(t, planPath) + if !plan.Diff.Empty() { + t.Fatalf("expected empty plan to be written") + } +} + +func testOperationPlan() *backend.Operation { + return &backend.Operation{ + Type: backend.OperationTypePlan, + } +} + +// testPlanState is just a common state that we use for testing refresh. +func testPlanState() *terraform.State { + return &terraform.State{ + Version: 2, + Modules: []*terraform.ModuleState{ + &terraform.ModuleState{ + Path: []string{"root"}, + Resources: map[string]*terraform.ResourceState{ + "test_instance.foo": &terraform.ResourceState{ + Type: "test_instance", + Primary: &terraform.InstanceState{ + ID: "bar", + }, + }, + }, + }, + }, + } +} + +func testReadPlan(t *testing.T, path string) *terraform.Plan { + f, err := os.Open(path) + if err != nil { + t.Fatalf("err: %s", err) + } + defer f.Close() + + p, err := terraform.ReadPlan(f) + if err != nil { + t.Fatalf("err: %s", err) + } + + return p +} diff --git a/backend/local/backend_refresh.go b/backend/local/backend_refresh.go new file mode 100644 index 000000000..a79ffd6af --- /dev/null +++ b/backend/local/backend_refresh.go @@ -0,0 +1,69 @@ +package local + +import ( + "context" + "fmt" + "os" + + "github.com/hashicorp/errwrap" + "github.com/hashicorp/terraform/backend" +) + +func (b *Local) opRefresh( + ctx context.Context, + op *backend.Operation, + runningOp *backend.RunningOperation) { + // Check if our state exists if we're performing a refresh operation. We + // only do this if we're managing state with this backend. + if b.Backend == nil { + if _, err := os.Stat(b.StatePath); err != nil { + if os.IsNotExist(err) { + runningOp.Err = fmt.Errorf( + "The Terraform state file for your infrastructure does not\n"+ + "exist. The 'refresh' command only works and only makes sense\n"+ + "when there is existing state that Terraform is managing. Please\n"+ + "double-check the value given below and try again. If you\n"+ + "haven't created infrastructure with Terraform yet, use the\n"+ + "'terraform apply' command.\n\n"+ + "Path: %s", + b.StatePath) + return + } + + runningOp.Err = fmt.Errorf( + "There was an error reading the Terraform state that is needed\n"+ + "for refreshing. The path and error are shown below.\n\n"+ + "Path: %s\n\nError: %s", + b.StatePath, err) + return + } + } + + // Get our context + tfCtx, state, err := b.context(op) + if err != nil { + runningOp.Err = err + return + } + + // Set our state + runningOp.State = state.State() + + // Perform operation and write the resulting state to the running op + newState, err := tfCtx.Refresh() + runningOp.State = newState + if err != nil { + runningOp.Err = errwrap.Wrapf("Error refreshing state: {{err}}", err) + return + } + + // Write and persist the state + if err := state.WriteState(newState); err != nil { + runningOp.Err = errwrap.Wrapf("Error writing state: {{err}}", err) + return + } + if err := state.PersistState(); err != nil { + runningOp.Err = errwrap.Wrapf("Error saving state: {{err}}", err) + return + } +} diff --git a/backend/local/backend_refresh_test.go b/backend/local/backend_refresh_test.go new file mode 100644 index 000000000..e52060f61 --- /dev/null +++ b/backend/local/backend_refresh_test.go @@ -0,0 +1,144 @@ +package local + +import ( + "context" + "fmt" + "testing" + + "github.com/hashicorp/terraform/backend" + "github.com/hashicorp/terraform/config/module" + "github.com/hashicorp/terraform/terraform" +) + +func TestLocal_refresh(t *testing.T) { + b := TestLocal(t) + p := TestLocalProvider(t, b, "test") + terraform.TestStateFile(t, b.StatePath, testRefreshState()) + + p.RefreshFn = nil + p.RefreshReturn = &terraform.InstanceState{ID: "yes"} + + mod, modCleanup := module.TestTree(t, "./test-fixtures/refresh") + defer modCleanup() + + op := testOperationRefresh() + op.Module = mod + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("bad: %s", err) + } + <-run.Done() + + if !p.RefreshCalled { + t.Fatal("refresh should be called") + } + + checkState(t, b.StateOutPath, ` +test_instance.foo: + ID = yes + `) +} + +func TestLocal_refreshInput(t *testing.T) { + b := TestLocal(t) + p := TestLocalProvider(t, b, "test") + terraform.TestStateFile(t, b.StatePath, testRefreshState()) + + p.ConfigureFn = func(c *terraform.ResourceConfig) error { + if v, ok := c.Get("value"); !ok || v != "bar" { + return fmt.Errorf("no value set") + } + + return nil + } + + p.RefreshFn = nil + p.RefreshReturn = &terraform.InstanceState{ID: "yes"} + + mod, modCleanup := module.TestTree(t, "./test-fixtures/refresh-var-unset") + defer modCleanup() + + // Enable input asking since it is normally disabled by default + b.OpInput = true + b.ContextOpts.UIInput = &terraform.MockUIInput{InputReturnString: "bar"} + + op := testOperationRefresh() + op.Module = mod + op.UIIn = b.ContextOpts.UIInput + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("bad: %s", err) + } + <-run.Done() + + if !p.RefreshCalled { + t.Fatal("refresh should be called") + } + + checkState(t, b.StateOutPath, ` +test_instance.foo: + ID = yes + `) +} + +func TestLocal_refreshValidate(t *testing.T) { + b := TestLocal(t) + p := TestLocalProvider(t, b, "test") + terraform.TestStateFile(t, b.StatePath, testRefreshState()) + + p.RefreshFn = nil + p.RefreshReturn = &terraform.InstanceState{ID: "yes"} + + mod, modCleanup := module.TestTree(t, "./test-fixtures/refresh") + defer modCleanup() + + // Enable validation + b.OpValidation = true + + op := testOperationRefresh() + op.Module = mod + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("bad: %s", err) + } + <-run.Done() + + if !p.ValidateCalled { + t.Fatal("validate should be called") + } + + checkState(t, b.StateOutPath, ` +test_instance.foo: + ID = yes + `) +} + +func testOperationRefresh() *backend.Operation { + return &backend.Operation{ + Type: backend.OperationTypeRefresh, + } +} + +// testRefreshState is just a common state that we use for testing refresh. +func testRefreshState() *terraform.State { + return &terraform.State{ + Version: 2, + Modules: []*terraform.ModuleState{ + &terraform.ModuleState{ + Path: []string{"root"}, + Resources: map[string]*terraform.ResourceState{ + "test_instance.foo": &terraform.ResourceState{ + Type: "test_instance", + Primary: &terraform.InstanceState{ + ID: "bar", + }, + }, + }, + Outputs: map[string]*terraform.OutputState{}, + }, + }, + } +} diff --git a/backend/local/backend_test.go b/backend/local/backend_test.go new file mode 100644 index 000000000..6fb7865b1 --- /dev/null +++ b/backend/local/backend_test.go @@ -0,0 +1,35 @@ +package local + +import ( + "os" + "strings" + "testing" + + "github.com/hashicorp/terraform/backend" + "github.com/hashicorp/terraform/terraform" +) + +func TestLocal_impl(t *testing.T) { + var _ backend.Enhanced = new(Local) + var _ backend.Local = new(Local) +} + +func checkState(t *testing.T, path, expected string) { + // Read the state + f, err := os.Open(path) + if err != nil { + t.Fatalf("err: %s", err) + } + + state, err := terraform.ReadState(f) + f.Close() + if err != nil { + t.Fatalf("err: %s", err) + } + + actual := strings.TrimSpace(state.String()) + expected = strings.TrimSpace(expected) + if actual != expected { + t.Fatalf("state does not match! actual:\n%s\n\nexpected:\n%s", actual, expected) + } +} diff --git a/backend/local/counthookaction_string.go b/backend/local/counthookaction_string.go new file mode 100644 index 000000000..574a8c6cb --- /dev/null +++ b/backend/local/counthookaction_string.go @@ -0,0 +1,16 @@ +// Code generated by "stringer -type=countHookAction hook_count_action.go"; DO NOT EDIT + +package local + +import "fmt" + +const _countHookAction_name = "countHookActionAddcountHookActionChangecountHookActionRemove" + +var _countHookAction_index = [...]uint8{0, 18, 39, 60} + +func (i countHookAction) String() string { + if i >= countHookAction(len(_countHookAction_index)-1) { + return fmt.Sprintf("countHookAction(%d)", i) + } + return _countHookAction_name[_countHookAction_index[i]:_countHookAction_index[i+1]] +} diff --git a/backend/local/hook_count.go b/backend/local/hook_count.go new file mode 100644 index 000000000..9c2d030b0 --- /dev/null +++ b/backend/local/hook_count.go @@ -0,0 +1,111 @@ +package local + +import ( + "strings" + "sync" + + "github.com/hashicorp/terraform/terraform" +) + +// CountHook is a hook that counts the number of resources +// added, removed, changed during the course of an apply. +type CountHook struct { + Added int + Changed int + Removed int + + ToAdd int + ToChange int + ToRemove int + ToRemoveAndAdd int + + pending map[string]countHookAction + + sync.Mutex + terraform.NilHook +} + +func (h *CountHook) Reset() { + h.Lock() + defer h.Unlock() + + h.pending = nil + h.Added = 0 + h.Changed = 0 + h.Removed = 0 +} + +func (h *CountHook) PreApply( + n *terraform.InstanceInfo, + s *terraform.InstanceState, + d *terraform.InstanceDiff) (terraform.HookAction, error) { + h.Lock() + defer h.Unlock() + + if h.pending == nil { + h.pending = make(map[string]countHookAction) + } + + action := countHookActionChange + if d.GetDestroy() { + action = countHookActionRemove + } else if s.ID == "" { + action = countHookActionAdd + } + + h.pending[n.HumanId()] = action + + return terraform.HookActionContinue, nil +} + +func (h *CountHook) PostApply( + n *terraform.InstanceInfo, + s *terraform.InstanceState, + e error) (terraform.HookAction, error) { + h.Lock() + defer h.Unlock() + + if h.pending != nil { + if a, ok := h.pending[n.HumanId()]; ok { + delete(h.pending, n.HumanId()) + + if e == nil { + switch a { + case countHookActionAdd: + h.Added += 1 + case countHookActionChange: + h.Changed += 1 + case countHookActionRemove: + h.Removed += 1 + } + } + } + } + + return terraform.HookActionContinue, nil +} + +func (h *CountHook) PostDiff( + n *terraform.InstanceInfo, d *terraform.InstanceDiff) ( + terraform.HookAction, error) { + h.Lock() + defer h.Unlock() + + // We don't count anything for data sources + if strings.HasPrefix(n.Id, "data.") { + return terraform.HookActionContinue, nil + } + + switch d.ChangeType() { + case terraform.DiffDestroyCreate: + h.ToRemoveAndAdd += 1 + case terraform.DiffCreate: + h.ToAdd += 1 + case terraform.DiffDestroy: + h.ToRemove += 1 + case terraform.DiffUpdate: + h.ToChange += 1 + } + + return terraform.HookActionContinue, nil +} diff --git a/backend/local/hook_count_action.go b/backend/local/hook_count_action.go new file mode 100644 index 000000000..9a28464c2 --- /dev/null +++ b/backend/local/hook_count_action.go @@ -0,0 +1,11 @@ +package local + +//go:generate stringer -type=countHookAction hook_count_action.go + +type countHookAction byte + +const ( + countHookActionAdd countHookAction = iota + countHookActionChange + countHookActionRemove +) diff --git a/backend/local/hook_count_test.go b/backend/local/hook_count_test.go new file mode 100644 index 000000000..45e6e20d9 --- /dev/null +++ b/backend/local/hook_count_test.go @@ -0,0 +1,243 @@ +package local + +import ( + "reflect" + "testing" + + "github.com/hashicorp/terraform/terraform" +) + +func TestCountHook_impl(t *testing.T) { + var _ terraform.Hook = new(CountHook) +} + +func TestCountHookPostDiff_DestroyDeposed(t *testing.T) { + h := new(CountHook) + + resources := map[string]*terraform.InstanceDiff{ + "lorem": &terraform.InstanceDiff{DestroyDeposed: true}, + } + + n := &terraform.InstanceInfo{} // TODO + + for _, d := range resources { + h.PostDiff(n, d) + } + + expected := new(CountHook) + expected.ToAdd = 0 + expected.ToChange = 0 + expected.ToRemoveAndAdd = 0 + expected.ToRemove = 1 + + if !reflect.DeepEqual(expected, h) { + t.Fatalf("Expected %#v, got %#v instead.", + expected, h) + } +} + +func TestCountHookPostDiff_DestroyOnly(t *testing.T) { + h := new(CountHook) + + resources := map[string]*terraform.InstanceDiff{ + "foo": &terraform.InstanceDiff{Destroy: true}, + "bar": &terraform.InstanceDiff{Destroy: true}, + "lorem": &terraform.InstanceDiff{Destroy: true}, + "ipsum": &terraform.InstanceDiff{Destroy: true}, + } + + n := &terraform.InstanceInfo{} // TODO + + for _, d := range resources { + h.PostDiff(n, d) + } + + expected := new(CountHook) + expected.ToAdd = 0 + expected.ToChange = 0 + expected.ToRemoveAndAdd = 0 + expected.ToRemove = 4 + + if !reflect.DeepEqual(expected, h) { + t.Fatalf("Expected %#v, got %#v instead.", + expected, h) + } +} + +func TestCountHookPostDiff_AddOnly(t *testing.T) { + h := new(CountHook) + + resources := map[string]*terraform.InstanceDiff{ + "foo": &terraform.InstanceDiff{ + Attributes: map[string]*terraform.ResourceAttrDiff{ + "foo": &terraform.ResourceAttrDiff{RequiresNew: true}, + }, + }, + "bar": &terraform.InstanceDiff{ + Attributes: map[string]*terraform.ResourceAttrDiff{ + "foo": &terraform.ResourceAttrDiff{RequiresNew: true}, + }, + }, + "lorem": &terraform.InstanceDiff{ + Attributes: map[string]*terraform.ResourceAttrDiff{ + "foo": &terraform.ResourceAttrDiff{RequiresNew: true}, + }, + }, + } + + n := &terraform.InstanceInfo{} + + for _, d := range resources { + h.PostDiff(n, d) + } + + expected := new(CountHook) + expected.ToAdd = 3 + expected.ToChange = 0 + expected.ToRemoveAndAdd = 0 + expected.ToRemove = 0 + + if !reflect.DeepEqual(expected, h) { + t.Fatalf("Expected %#v, got %#v instead.", + expected, h) + } +} + +func TestCountHookPostDiff_ChangeOnly(t *testing.T) { + h := new(CountHook) + + resources := map[string]*terraform.InstanceDiff{ + "foo": &terraform.InstanceDiff{ + Destroy: false, + Attributes: map[string]*terraform.ResourceAttrDiff{ + "foo": &terraform.ResourceAttrDiff{}, + }, + }, + "bar": &terraform.InstanceDiff{ + Destroy: false, + Attributes: map[string]*terraform.ResourceAttrDiff{ + "foo": &terraform.ResourceAttrDiff{}, + }, + }, + "lorem": &terraform.InstanceDiff{ + Destroy: false, + Attributes: map[string]*terraform.ResourceAttrDiff{ + "foo": &terraform.ResourceAttrDiff{}, + }, + }, + } + + n := &terraform.InstanceInfo{} + + for _, d := range resources { + h.PostDiff(n, d) + } + + expected := new(CountHook) + expected.ToAdd = 0 + expected.ToChange = 3 + expected.ToRemoveAndAdd = 0 + expected.ToRemove = 0 + + if !reflect.DeepEqual(expected, h) { + t.Fatalf("Expected %#v, got %#v instead.", + expected, h) + } +} + +func TestCountHookPostDiff_Mixed(t *testing.T) { + h := new(CountHook) + + resources := map[string]*terraform.InstanceDiff{ + "foo": &terraform.InstanceDiff{ + Destroy: true, + }, + "bar": &terraform.InstanceDiff{}, + "lorem": &terraform.InstanceDiff{ + Destroy: false, + Attributes: map[string]*terraform.ResourceAttrDiff{ + "foo": &terraform.ResourceAttrDiff{}, + }, + }, + "ipsum": &terraform.InstanceDiff{Destroy: true}, + } + + n := &terraform.InstanceInfo{} + + for _, d := range resources { + h.PostDiff(n, d) + } + + expected := new(CountHook) + expected.ToAdd = 0 + expected.ToChange = 1 + expected.ToRemoveAndAdd = 0 + expected.ToRemove = 2 + + if !reflect.DeepEqual(expected, h) { + t.Fatalf("Expected %#v, got %#v instead.", + expected, h) + } +} + +func TestCountHookPostDiff_NoChange(t *testing.T) { + h := new(CountHook) + + resources := map[string]*terraform.InstanceDiff{ + "foo": &terraform.InstanceDiff{}, + "bar": &terraform.InstanceDiff{}, + "lorem": &terraform.InstanceDiff{}, + "ipsum": &terraform.InstanceDiff{}, + } + + n := &terraform.InstanceInfo{} + + for _, d := range resources { + h.PostDiff(n, d) + } + + expected := new(CountHook) + expected.ToAdd = 0 + expected.ToChange = 0 + expected.ToRemoveAndAdd = 0 + expected.ToRemove = 0 + + if !reflect.DeepEqual(expected, h) { + t.Fatalf("Expected %#v, got %#v instead.", + expected, h) + } +} + +func TestCountHookPostDiff_DataSource(t *testing.T) { + h := new(CountHook) + + resources := map[string]*terraform.InstanceDiff{ + "data.foo": &terraform.InstanceDiff{ + Destroy: true, + }, + "data.bar": &terraform.InstanceDiff{}, + "data.lorem": &terraform.InstanceDiff{ + Destroy: false, + Attributes: map[string]*terraform.ResourceAttrDiff{ + "foo": &terraform.ResourceAttrDiff{}, + }, + }, + "data.ipsum": &terraform.InstanceDiff{Destroy: true}, + } + + for k, d := range resources { + n := &terraform.InstanceInfo{Id: k} + h.PostDiff(n, d) + } + + expected := new(CountHook) + expected.ToAdd = 0 + expected.ToChange = 0 + expected.ToRemoveAndAdd = 0 + expected.ToRemove = 0 + + if !reflect.DeepEqual(expected, h) { + t.Fatalf("Expected %#v, got %#v instead.", + expected, h) + } +} diff --git a/backend/local/hook_state.go b/backend/local/hook_state.go new file mode 100644 index 000000000..5483c4344 --- /dev/null +++ b/backend/local/hook_state.go @@ -0,0 +1,33 @@ +package local + +import ( + "sync" + + "github.com/hashicorp/terraform/state" + "github.com/hashicorp/terraform/terraform" +) + +// StateHook is a hook that continuously updates the state by calling +// WriteState on a state.State. +type StateHook struct { + terraform.NilHook + sync.Mutex + + State state.State +} + +func (h *StateHook) PostStateUpdate( + s *terraform.State) (terraform.HookAction, error) { + h.Lock() + defer h.Unlock() + + if h.State != nil { + // Write the new state + if err := h.State.WriteState(s); err != nil { + return terraform.HookActionHalt, err + } + } + + // Continue forth + return terraform.HookActionContinue, nil +} diff --git a/backend/local/hook_state_test.go b/backend/local/hook_state_test.go new file mode 100644 index 000000000..7f4d88770 --- /dev/null +++ b/backend/local/hook_state_test.go @@ -0,0 +1,29 @@ +package local + +import ( + "testing" + + "github.com/hashicorp/terraform/state" + "github.com/hashicorp/terraform/terraform" +) + +func TestStateHook_impl(t *testing.T) { + var _ terraform.Hook = new(StateHook) +} + +func TestStateHook(t *testing.T) { + is := &state.InmemState{} + var hook terraform.Hook = &StateHook{State: is} + + s := state.TestStateInitial() + action, err := hook.PostStateUpdate(s) + if err != nil { + t.Fatalf("err: %s", err) + } + if action != terraform.HookActionContinue { + t.Fatalf("bad: %v", action) + } + if !is.State().Equal(s) { + t.Fatalf("bad state: %#v", is.State()) + } +} diff --git a/backend/local/local_test.go b/backend/local/local_test.go new file mode 100644 index 000000000..70c25e24e --- /dev/null +++ b/backend/local/local_test.go @@ -0,0 +1,25 @@ +package local + +import ( + "flag" + "io/ioutil" + "log" + "os" + "testing" + + "github.com/hashicorp/terraform/helper/logging" +) + +func TestMain(m *testing.M) { + flag.Parse() + + if testing.Verbose() { + // if we're verbose, use the logging requested by TF_LOG + logging.SetOutput() + } else { + // otherwise silence all logs + log.SetOutput(ioutil.Discard) + } + + os.Exit(m.Run()) +} diff --git a/backend/local/test-fixtures/apply-error/main.tf b/backend/local/test-fixtures/apply-error/main.tf new file mode 100644 index 000000000..a6d6cc0df --- /dev/null +++ b/backend/local/test-fixtures/apply-error/main.tf @@ -0,0 +1,7 @@ +resource "test_instance" "foo" { + ami = "bar" +} + +resource "test_instance" "bar" { + error = "true" +} diff --git a/backend/local/test-fixtures/apply/main.tf b/backend/local/test-fixtures/apply/main.tf new file mode 100644 index 000000000..1b1012991 --- /dev/null +++ b/backend/local/test-fixtures/apply/main.tf @@ -0,0 +1,3 @@ +resource "test_instance" "foo" { + ami = "bar" +} diff --git a/backend/local/test-fixtures/plan/main.tf b/backend/local/test-fixtures/plan/main.tf new file mode 100644 index 000000000..fd9da13e0 --- /dev/null +++ b/backend/local/test-fixtures/plan/main.tf @@ -0,0 +1,9 @@ +resource "test_instance" "foo" { + ami = "bar" + + # This is here because at some point it caused a test failure + network_interface { + device_index = 0 + description = "Main network interface" + } +} diff --git a/backend/local/test-fixtures/refresh-var-unset/main.tf b/backend/local/test-fixtures/refresh-var-unset/main.tf new file mode 100644 index 000000000..7d6b13014 --- /dev/null +++ b/backend/local/test-fixtures/refresh-var-unset/main.tf @@ -0,0 +1,9 @@ +variable "should_ask" {} + +provider "test" { + value = "${var.should_ask}" +} + +resource "test_instance" "foo" { + foo = "bar" +} diff --git a/backend/local/test-fixtures/refresh/main.tf b/backend/local/test-fixtures/refresh/main.tf new file mode 100644 index 000000000..1b1012991 --- /dev/null +++ b/backend/local/test-fixtures/refresh/main.tf @@ -0,0 +1,3 @@ +resource "test_instance" "foo" { + ami = "bar" +} diff --git a/backend/local/testing.go b/backend/local/testing.go new file mode 100644 index 000000000..e5920f36d --- /dev/null +++ b/backend/local/testing.go @@ -0,0 +1,66 @@ +package local + +import ( + "io/ioutil" + "path/filepath" + "testing" + + "github.com/hashicorp/terraform/terraform" +) + +// TestLocal returns a configured Local struct with temporary paths and +// in-memory ContextOpts. +// +// No operations will be called on the returned value, so you can still set +// public fields without any locks. +func TestLocal(t *testing.T) *Local { + tempDir := testTempDir(t) + return &Local{ + StatePath: filepath.Join(tempDir, "state.tfstate"), + StateOutPath: filepath.Join(tempDir, "state.tfstate"), + StateBackupPath: filepath.Join(tempDir, "state.tfstate.bak"), + ContextOpts: &terraform.ContextOpts{}, + } +} + +// TestLocalProvider modifies the ContextOpts of the *Local parameter to +// have a provider with the given name. +func TestLocalProvider(t *testing.T, b *Local, name string) *terraform.MockResourceProvider { + // Build a mock resource provider for in-memory operations + p := new(terraform.MockResourceProvider) + p.DiffReturn = &terraform.InstanceDiff{} + p.RefreshFn = func( + info *terraform.InstanceInfo, + s *terraform.InstanceState) (*terraform.InstanceState, error) { + return s, nil + } + p.ResourcesReturn = []terraform.ResourceType{ + terraform.ResourceType{ + Name: "test_instance", + }, + } + + // Initialize the opts + if b.ContextOpts == nil { + b.ContextOpts = &terraform.ContextOpts{} + } + if b.ContextOpts.Providers == nil { + b.ContextOpts.Providers = make(map[string]terraform.ResourceProviderFactory) + } + + // Setup our provider + b.ContextOpts.Providers[name] = func() (terraform.ResourceProvider, error) { + return p, nil + } + + return p +} + +func testTempDir(t *testing.T) string { + d, err := ioutil.TempDir("", "tf") + if err != nil { + t.Fatalf("err: %s", err) + } + + return d +}