opentofu/backend/local/backend_apply_test.go
Kristin Laemmert 86e9ba3d65
* backend/local: push responsibility for unlocking state into individual operations
* unlock the state if Context() has an error, exactly as backend/remote does today
* terraform console and terraform import will exit before unlocking state in case of error in Context()
* responsibility for unlocking state in the local backend is pushed down the stack, out of backend.go and into each individual state operation
* add tests confirming that state is not locked after apply and plan

* backend/local: add checks that the state is unlocked after operations

This adds tests to plan, apply and refresh which validate that the state
is unlocked after all operations, regardless of exit status. I've also
added specific tests that force Context() to fail during each operation
to verify that locking behavior specifically.
2020-08-11 11:23:42 -04:00

289 lines
7.1 KiB
Go

package local
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/mitchellh/cli"
"github.com/zclconf/go-cty/cty"
"github.com/hashicorp/terraform/backend"
"github.com/hashicorp/terraform/configs/configschema"
"github.com/hashicorp/terraform/internal/initwd"
"github.com/hashicorp/terraform/providers"
"github.com/hashicorp/terraform/states"
"github.com/hashicorp/terraform/states/statemgr"
"github.com/hashicorp/terraform/terraform"
"github.com/hashicorp/terraform/tfdiags"
)
func TestLocal_applyBasic(t *testing.T) {
b, cleanup := TestLocal(t)
defer cleanup()
p := TestLocalProvider(t, b, "test", applyFixtureSchema())
p.ApplyResourceChangeResponse = providers.ApplyResourceChangeResponse{NewState: cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("yes"),
"ami": cty.StringVal("bar"),
})}
op, configCleanup := testOperationApply(t, "./testdata/apply")
defer configCleanup()
run, err := b.Operation(context.Background(), op)
if err != nil {
t.Fatalf("bad: %s", err)
}
<-run.Done()
if run.Result != backend.OperationSuccess {
t.Fatal("operation failed")
}
if p.ReadResourceCalled {
t.Fatal("ReadResource should not be called")
}
if !p.PlanResourceChangeCalled {
t.Fatal("diff should be called")
}
if !p.ApplyResourceChangeCalled {
t.Fatal("apply should be called")
}
checkState(t, b.StateOutPath, `
test_instance.foo:
ID = yes
provider = provider["registry.terraform.io/hashicorp/test"]
ami = bar
`)
}
func TestLocal_applyEmptyDir(t *testing.T) {
b, cleanup := TestLocal(t)
defer cleanup()
p := TestLocalProvider(t, b, "test", &terraform.ProviderSchema{})
p.ApplyResourceChangeResponse = providers.ApplyResourceChangeResponse{NewState: cty.ObjectVal(map[string]cty.Value{"id": cty.StringVal("yes")})}
op, configCleanup := testOperationApply(t, "./testdata/empty")
defer configCleanup()
run, err := b.Operation(context.Background(), op)
if err != nil {
t.Fatalf("bad: %s", err)
}
<-run.Done()
if run.Result == backend.OperationSuccess {
t.Fatal("operation succeeded; want error")
}
if p.ApplyResourceChangeCalled {
t.Fatal("apply should not be called")
}
if _, err := os.Stat(b.StateOutPath); err == nil {
t.Fatal("should not exist")
}
// the backend should be unlocked after a run
assertBackendStateUnlocked(t, b)
}
func TestLocal_applyEmptyDirDestroy(t *testing.T) {
b, cleanup := TestLocal(t)
defer cleanup()
p := TestLocalProvider(t, b, "test", &terraform.ProviderSchema{})
p.ApplyResourceChangeResponse = providers.ApplyResourceChangeResponse{}
op, configCleanup := testOperationApply(t, "./testdata/empty")
defer configCleanup()
op.Destroy = true
run, err := b.Operation(context.Background(), op)
if err != nil {
t.Fatalf("bad: %s", err)
}
<-run.Done()
if run.Result != backend.OperationSuccess {
t.Fatalf("apply operation failed")
}
if p.ApplyResourceChangeCalled {
t.Fatal("apply should not be called")
}
checkState(t, b.StateOutPath, `<no state>`)
}
func TestLocal_applyError(t *testing.T) {
b, cleanup := TestLocal(t)
defer cleanup()
schema := &terraform.ProviderSchema{
ResourceTypes: map[string]*configschema.Block{
"test_instance": {
Attributes: map[string]*configschema.Attribute{
"ami": {Type: cty.String, Optional: true},
"id": {Type: cty.String, Computed: true},
},
},
},
}
p := TestLocalProvider(t, b, "test", schema)
var lock sync.Mutex
errored := false
p.ApplyResourceChangeFn = func(
r providers.ApplyResourceChangeRequest) providers.ApplyResourceChangeResponse {
lock.Lock()
defer lock.Unlock()
var diags tfdiags.Diagnostics
ami := r.Config.GetAttr("ami").AsString()
if !errored && ami == "error" {
errored = true
diags = diags.Append(errors.New("error"))
return providers.ApplyResourceChangeResponse{
Diagnostics: diags,
}
}
return providers.ApplyResourceChangeResponse{
Diagnostics: diags,
NewState: cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("foo"),
"ami": cty.StringVal("bar"),
}),
}
}
op, configCleanup := testOperationApply(t, "./testdata/apply-error")
defer configCleanup()
run, err := b.Operation(context.Background(), op)
if err != nil {
t.Fatalf("bad: %s", err)
}
<-run.Done()
if run.Result == backend.OperationSuccess {
t.Fatal("operation succeeded; want failure")
}
checkState(t, b.StateOutPath, `
test_instance.foo:
ID = foo
provider = provider["registry.terraform.io/hashicorp/test"]
ami = bar
`)
// the backend should be unlocked after a run
assertBackendStateUnlocked(t, b)
}
func TestLocal_applyBackendFail(t *testing.T) {
b, cleanup := TestLocal(t)
defer cleanup()
p := TestLocalProvider(t, b, "test", applyFixtureSchema())
p.ApplyResourceChangeResponse = providers.ApplyResourceChangeResponse{NewState: cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("yes"),
"ami": cty.StringVal("bar"),
})}
wd, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get current working directory")
}
err = os.Chdir(filepath.Dir(b.StatePath))
if err != nil {
t.Fatalf("failed to set temporary working directory")
}
defer os.Chdir(wd)
op, configCleanup := testOperationApply(t, wd+"/testdata/apply")
defer configCleanup()
b.Backend = &backendWithFailingState{}
b.CLI = new(cli.MockUi)
run, err := b.Operation(context.Background(), op)
if err != nil {
t.Fatalf("bad: %s", err)
}
<-run.Done()
if run.Result == backend.OperationSuccess {
t.Fatalf("apply succeeded; want error")
}
msgStr := b.CLI.(*cli.MockUi).ErrorWriter.String()
if !strings.Contains(msgStr, "Failed to save state: fake failure") {
t.Fatalf("missing \"fake failure\" message in output:\n%s", msgStr)
}
// The fallback behavior should've created a file errored.tfstate in the
// current working directory.
checkState(t, "errored.tfstate", `
test_instance.foo:
ID = yes
provider = provider["registry.terraform.io/hashicorp/test"]
ami = bar
`)
// the backend should be unlocked after a run
assertBackendStateUnlocked(t, b)
}
type backendWithFailingState struct {
Local
}
func (b *backendWithFailingState) StateMgr(name string) (statemgr.Full, error) {
return &failingState{
statemgr.NewFilesystem("failing-state.tfstate"),
}, nil
}
type failingState struct {
*statemgr.Filesystem
}
func (s failingState) WriteState(state *states.State) error {
return errors.New("fake failure")
}
func testOperationApply(t *testing.T, configDir string) (*backend.Operation, func()) {
t.Helper()
_, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir)
return &backend.Operation{
Type: backend.OperationTypeApply,
ConfigDir: configDir,
ConfigLoader: configLoader,
}, configCleanup
}
// applyFixtureSchema returns a schema suitable for processing the
// configuration in testdata/apply . This schema should be
// assigned to a mock provider named "test".
func applyFixtureSchema() *terraform.ProviderSchema {
return &terraform.ProviderSchema{
ResourceTypes: map[string]*configschema.Block{
"test_instance": {
Attributes: map[string]*configschema.Attribute{
"ami": {Type: cty.String, Optional: true},
"id": {Type: cty.String, Computed: true},
},
},
},
}
}