| // Copyright IBM Corp. 2014, 2026 | |
| // SPDX-License-Identifier: BUSL-1.1 | |
| package local | |
| import ( | |
| "context" | |
| "errors" | |
| "os" | |
| "path/filepath" | |
| "strings" | |
| "sync" | |
| "testing" | |
| "github.com/zclconf/go-cty/cty" | |
| "github.com/hashicorp/terraform/internal/addrs" | |
| "github.com/hashicorp/terraform/internal/backend/backendrun" | |
| "github.com/hashicorp/terraform/internal/command/arguments" | |
| "github.com/hashicorp/terraform/internal/command/clistate" | |
| "github.com/hashicorp/terraform/internal/command/views" | |
| "github.com/hashicorp/terraform/internal/configs/configschema" | |
| "github.com/hashicorp/terraform/internal/depsfile" | |
| "github.com/hashicorp/terraform/internal/plans" | |
| "github.com/hashicorp/terraform/internal/providers" | |
| "github.com/hashicorp/terraform/internal/states" | |
| "github.com/hashicorp/terraform/internal/states/statemgr" | |
| "github.com/hashicorp/terraform/internal/terminal" | |
| tftesting "github.com/hashicorp/terraform/internal/terraform/testing" | |
| "github.com/hashicorp/terraform/internal/tfdiags" | |
| ) | |
| func TestLocal_applyBasic(t *testing.T) { | |
| b := TestLocal(t) | |
| 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, done := 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 != backendrun.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 | |
| `) | |
| if errOutput := done(t).Stderr(); errOutput != "" { | |
| t.Fatalf("unexpected error output:\n%s", errOutput) | |
| } | |
| } | |
| func TestLocal_applyCheck(t *testing.T) { | |
| b := TestLocal(t) | |
| 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, done := testOperationApply(t, "./testdata/apply-check") | |
| defer configCleanup() | |
| run, err := b.Operation(context.Background(), op) | |
| if err != nil { | |
| t.Fatalf("bad: %s", err) | |
| } | |
| <-run.Done() | |
| if run.Result != backendrun.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") | |
| } | |
| d := done(t) | |
| if errOutput := d.Stderr(); errOutput != "" { | |
| t.Fatalf("unexpected error output:\n%s", errOutput) | |
| } | |
| if stdOutput := d.Stdout(); strings.Contains(stdOutput, "Check block assertion known after apply") { | |
| // As we are running an auto approved plan the warning that was | |
| // generated during the plan should have been hidden. | |
| t.Fatalf("std output contained unexpected check output:\n%s", stdOutput) | |
| } | |
| } | |
| func TestLocal_applyEmptyDir(t *testing.T) { | |
| b := TestLocal(t) | |
| p := TestLocalProvider(t, b, "test", providers.ProviderSchema{}) | |
| p.ApplyResourceChangeResponse = &providers.ApplyResourceChangeResponse{NewState: cty.ObjectVal(map[string]cty.Value{"id": cty.StringVal("yes")})} | |
| op, configCleanup, done := 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 == backendrun.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) | |
| if got, want := done(t).Stderr(), "Error: No configuration files"; !strings.Contains(got, want) { | |
| t.Fatalf("unexpected error output:\n%s\nwant: %s", got, want) | |
| } | |
| } | |
| func TestLocal_applyEmptyDirDestroy(t *testing.T) { | |
| b := TestLocal(t) | |
| p := TestLocalProvider(t, b, "test", providers.ProviderSchema{}) | |
| p.ApplyResourceChangeResponse = &providers.ApplyResourceChangeResponse{} | |
| op, configCleanup, done := testOperationApply(t, "./testdata/empty") | |
| defer configCleanup() | |
| op.PlanMode = plans.DestroyMode | |
| run, err := b.Operation(context.Background(), op) | |
| if err != nil { | |
| t.Fatalf("bad: %s", err) | |
| } | |
| <-run.Done() | |
| if run.Result != backendrun.OperationSuccess { | |
| t.Fatalf("apply operation failed") | |
| } | |
| if p.ApplyResourceChangeCalled { | |
| t.Fatal("apply should not be called") | |
| } | |
| checkState(t, b.StateOutPath, `<no state>`) | |
| if errOutput := done(t).Stderr(); errOutput != "" { | |
| t.Fatalf("unexpected error output:\n%s", errOutput) | |
| } | |
| } | |
| func TestLocal_applyError(t *testing.T) { | |
| b := TestLocal(t) | |
| schema := providers.ProviderSchema{ | |
| ResourceTypes: map[string]providers.Schema{ | |
| "test_instance": { | |
| Body: &configschema.Block{ | |
| 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("ami 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, done := 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 == backendrun.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) | |
| if got, want := done(t).Stderr(), "Error: ami error"; !strings.Contains(got, want) { | |
| t.Fatalf("unexpected error output:\n%s\nwant: %s", got, want) | |
| } | |
| } | |
| func TestLocal_applyBackendFail(t *testing.T) { | |
| b := TestLocal(t) | |
| 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"), | |
| }), | |
| Diagnostics: tfdiags.Diagnostics.Append(nil, errors.New("error before backend failure")), | |
| } | |
| 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, done := testOperationApply(t, wd+"/testdata/apply") | |
| defer configCleanup() | |
| b.Backend = &backendWithFailingState{} | |
| run, err := b.Operation(context.Background(), op) | |
| if err != nil { | |
| t.Fatalf("bad: %s", err) | |
| } | |
| <-run.Done() | |
| output := done(t) | |
| if run.Result == backendrun.OperationSuccess { | |
| t.Fatalf("apply succeeded; want error") | |
| } | |
| diagErr := output.Stderr() | |
| if !strings.Contains(diagErr, "Error saving state: fake failure") { | |
| t.Fatalf("missing \"fake failure\" message in diags:\n%s", diagErr) | |
| } | |
| if !strings.Contains(diagErr, "error before backend failure") { | |
| t.Fatalf("missing 'error before backend failure' diagnostic from apply") | |
| } | |
| // The fallback behavior should've created a file errored.tfstate in the | |
| // current working directory. | |
| checkState(t, "errored.tfstate", ` | |
| test_instance.foo: (tainted) | |
| ID = yes | |
| provider = provider["registry.terraform.io/hashicorp/test"] | |
| ami = bar | |
| `) | |
| // the backend should be unlocked after a run | |
| assertBackendStateUnlocked(t, b) | |
| } | |
| func TestLocal_applyRefreshFalse(t *testing.T) { | |
| b := TestLocal(t) | |
| p := TestLocalProvider(t, b, "test", planFixtureSchema()) | |
| testStateFile(t, b.StatePath, testPlanState()) | |
| op, configCleanup, done := testOperationApply(t, "./testdata/plan") | |
| defer configCleanup() | |
| run, err := b.Operation(context.Background(), op) | |
| if err != nil { | |
| t.Fatalf("bad: %s", err) | |
| } | |
| <-run.Done() | |
| if run.Result != backendrun.OperationSuccess { | |
| t.Fatalf("plan operation failed") | |
| } | |
| if p.ReadResourceCalled { | |
| t.Fatal("ReadResource should not be called") | |
| } | |
| if errOutput := done(t).Stderr(); errOutput != "" { | |
| t.Fatalf("unexpected error output:\n%s", errOutput) | |
| } | |
| } | |
| type backendWithFailingState struct { | |
| Local | |
| } | |
| func (b *backendWithFailingState) StateMgr(name string) (statemgr.Full, tfdiags.Diagnostics) { | |
| 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) (*backendrun.Operation, func(), func(*testing.T) *terminal.TestOutput) { | |
| t.Helper() | |
| _, configLoader, configCleanup := tftesting.MustLoadConfigForTests(t, configDir, "tests") | |
| streams, done := terminal.StreamsForTesting(t) | |
| view := views.NewOperation(arguments.ViewHuman, false, views.NewView(streams)) | |
| // Many of our tests use an overridden "test" provider that's just in-memory | |
| // inside the test process, not a separate plugin on disk. | |
| depLocks := depsfile.NewLocks() | |
| depLocks.SetProviderOverridden(addrs.MustParseProviderSourceString("registry.terraform.io/hashicorp/test")) | |
| return &backendrun.Operation{ | |
| Type: backendrun.OperationTypeApply, | |
| ConfigDir: configDir, | |
| ConfigLoader: configLoader, | |
| StateLocker: clistate.NewNoopLocker(), | |
| View: view, | |
| DependencyLocks: depLocks, | |
| }, configCleanup, done | |
| } | |
| // 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() providers.ProviderSchema { | |
| return providers.ProviderSchema{ | |
| ResourceTypes: map[string]providers.Schema{ | |
| "test_instance": { | |
| Body: &configschema.Block{ | |
| Attributes: map[string]*configschema.Attribute{ | |
| "ami": {Type: cty.String, Optional: true}, | |
| "id": {Type: cty.String, Computed: true}, | |
| }, | |
| }, | |
| }, | |
| }, | |
| } | |
| } | |
| func TestApply_applyCanceledAutoApprove(t *testing.T) { | |
| b := TestLocal(t) | |
| TestLocalProvider(t, b, "test", applyFixtureSchema()) | |
| op, configCleanup, done := testOperationApply(t, "./testdata/apply") | |
| op.AutoApprove = true | |
| defer configCleanup() | |
| defer func() { | |
| output := done(t) | |
| if !strings.Contains(output.Stderr(), "execution halted") { | |
| t.Fatal("expected 'execution halted', got:\n", output.All()) | |
| } | |
| }() | |
| ctx, cancel := context.WithCancel(context.Background()) | |
| testHookStopPlanApply = cancel | |
| defer func() { | |
| testHookStopPlanApply = nil | |
| }() | |
| run, err := b.Operation(ctx, op) | |
| if err != nil { | |
| t.Fatalf("error starting operation: %v", err) | |
| } | |
| <-run.Done() | |
| if run.Result == backendrun.OperationSuccess { | |
| t.Fatal("expected apply operation to fail") | |
| } | |
| } |