backend/local: Fix lock when applying stale plan

When returning from the context method, a deferred function call checked
for error diagnostics in the `diags` variable, and unlocked the state if
any exist. This means that we need to be extra careful to mutate that
variable when returning errors, rather than returning a different set of
diags in the same position.

Previously this would result in an invalid plan file causing a lock to
become stuck.
This commit is contained in:
Alisdair McDiarmid 2021-05-26 16:05:58 -04:00
parent abf7f3416b
commit a3b1764fd7
2 changed files with 91 additions and 1 deletions

View File

@ -97,7 +97,8 @@ func (b *Local) context(op *backend.Operation) (*terraform.Context, *configload.
log.Printf("[TRACE] backend/local: building context from plan file") log.Printf("[TRACE] backend/local: building context from plan file")
tfCtx, configSnap, ctxDiags = b.contextFromPlanFile(op.PlanFile, opts, stateMeta) tfCtx, configSnap, ctxDiags = b.contextFromPlanFile(op.PlanFile, opts, stateMeta)
if ctxDiags.HasErrors() { if ctxDiags.HasErrors() {
return nil, nil, nil, ctxDiags diags = diags.Append(ctxDiags)
return nil, nil, nil, diags
} }
// Write sources into the cache of the main loader so that they are // Write sources into the cache of the main loader so that they are

View File

@ -1,14 +1,22 @@
package local package local
import ( import (
"os"
"path/filepath"
"testing" "testing"
"github.com/hashicorp/terraform/internal/backend" "github.com/hashicorp/terraform/internal/backend"
"github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/command/arguments"
"github.com/hashicorp/terraform/internal/command/clistate" "github.com/hashicorp/terraform/internal/command/clistate"
"github.com/hashicorp/terraform/internal/command/views" "github.com/hashicorp/terraform/internal/command/views"
"github.com/hashicorp/terraform/internal/configs/configload"
"github.com/hashicorp/terraform/internal/initwd" "github.com/hashicorp/terraform/internal/initwd"
"github.com/hashicorp/terraform/internal/plans"
"github.com/hashicorp/terraform/internal/plans/planfile"
"github.com/hashicorp/terraform/internal/states"
"github.com/hashicorp/terraform/internal/states/statefile"
"github.com/hashicorp/terraform/internal/terminal" "github.com/hashicorp/terraform/internal/terminal"
"github.com/zclconf/go-cty/cty"
) )
func TestLocalContext(t *testing.T) { func TestLocalContext(t *testing.T) {
@ -66,3 +74,84 @@ func TestLocalContext_error(t *testing.T) {
// Context() unlocks the state on failure // Context() unlocks the state on failure
assertBackendStateUnlocked(t, b) assertBackendStateUnlocked(t, b)
} }
func TestLocalContext_stalePlan(t *testing.T) {
configDir := "./testdata/apply"
b, cleanup := TestLocal(t)
defer cleanup()
_, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir)
defer configCleanup()
// Write an empty state file with serial 3
sf, err := os.Create(b.StatePath)
if err != nil {
t.Fatalf("unexpected error creating state file %s: %s", b.StatePath, err)
}
if err := statefile.Write(statefile.New(states.NewState(), "boop", 3), sf); err != nil {
t.Fatalf("unexpected error writing state file: %s", err)
}
// Refresh the state
sm, err := b.StateMgr("")
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if err := sm.RefreshState(); err != nil {
t.Fatalf("unexpected error refreshing state: %s", err)
}
// Create a minimal plan which also has state file serial 2, so is stale
backendConfig := cty.ObjectVal(map[string]cty.Value{
"path": cty.NullVal(cty.String),
"workspace_dir": cty.NullVal(cty.String),
})
backendConfigRaw, err := plans.NewDynamicValue(backendConfig, backendConfig.Type())
if err != nil {
t.Fatal(err)
}
plan := &plans.Plan{
UIMode: plans.NormalMode,
Changes: plans.NewChanges(),
Backend: plans.Backend{
Type: "local",
Config: backendConfigRaw,
},
PrevRunState: states.NewState(),
PriorState: states.NewState(),
}
prevStateFile := statefile.New(plan.PrevRunState, "boop", 1)
stateFile := statefile.New(plan.PriorState, "boop", 2)
// Roundtrip through serialization as expected by the operation
outDir := testTempDir(t)
defer os.RemoveAll(outDir)
planPath := filepath.Join(outDir, "plan.tfplan")
if err := planfile.Create(planPath, configload.NewEmptySnapshot(), prevStateFile, stateFile, plan); err != nil {
t.Fatalf("unexpected error writing planfile: %s", err)
}
planFile, err := planfile.Open(planPath)
if err != nil {
t.Fatalf("unexpected error reading planfile: %s", err)
}
streams, _ := terminal.StreamsForTesting(t)
view := views.NewView(streams)
stateLocker := clistate.NewLocker(0, views.NewStateLocker(arguments.ViewHuman, view))
op := &backend.Operation{
ConfigDir: configDir,
ConfigLoader: configLoader,
PlanFile: planFile,
Workspace: backend.DefaultStateName,
StateLocker: stateLocker,
}
_, _, diags := b.Context(op)
if !diags.HasErrors() {
t.Fatal("unexpected success")
}
// Context() unlocks the state on failure
assertBackendStateUnlocked(t, b)
}