mirror of
https://github.com/opentofu/opentofu.git
synced 2024-12-28 18:01:01 -06:00
df0a70bfb6
When executing an apply with no plan, it's possible for a cancellation to arrive during the final batch of provider operations, resulting in no errors in the plan. The run context was next checked during the confirmation for apply, but in the case of -auto-approve that confirmation is skipped, resulting in the canceled plan being applied. Make sure we directly check for cancellation before confirming the plan.
387 lines
10 KiB
Go
387 lines
10 KiB
Go
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"
|
|
"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/initwd"
|
|
"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"
|
|
"github.com/hashicorp/terraform/internal/terraform"
|
|
"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 != 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
|
|
`)
|
|
|
|
if errOutput := done(t).Stderr(); errOutput != "" {
|
|
t.Fatalf("unexpected error output:\n%s", errOutput)
|
|
}
|
|
}
|
|
|
|
func TestLocal_applyEmptyDir(t *testing.T) {
|
|
b := TestLocal(t)
|
|
|
|
p := TestLocalProvider(t, b, "test", &terraform.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 == 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)
|
|
|
|
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", &terraform.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 != backend.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 := &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("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 == 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)
|
|
|
|
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 == backend.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 != backend.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, 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(), func(*testing.T) *terminal.TestOutput) {
|
|
t.Helper()
|
|
|
|
_, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir)
|
|
|
|
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 &backend.Operation{
|
|
Type: backend.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() *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},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
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 == backend.OperationSuccess {
|
|
t.Fatal("expected apply operation to fail")
|
|
}
|
|
|
|
}
|