Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changes/unreleased/BUG FIXES-20260210-153921.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
kind: BUG FIXES
body: 'helper/resource: Test steps in `Config` mode using the `TF_ACC_REFRESH_AFTER_APPLY` compatibility flag will now force post-apply plans to refresh.'
time: 2026-02-10T15:39:21.121944-05:00
custom:
Issue: "602"
6 changes: 6 additions & 0 deletions .changes/unreleased/BUG FIXES-20260210-154312.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
kind: BUG FIXES
body: 'helper/resource: Test steps in `Config` mode using `Destroy: true` and `Check` functions will now create an additional destroy plan prior to
running `terraform apply` to avoid a potential "Saved Plan is Stale" error from Terraform.'
time: 2026-02-10T15:43:12.029656-05:00
custom:
Issue: "602"
5 changes: 5 additions & 0 deletions .changes/unreleased/BUG FIXES-20260210-154602.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
kind: BUG FIXES
body: 'helper/resource: Test steps in `Config` mode using the `TF_ACC_REFRESH_AFTER_APPLY` compatibility flag will not refresh if `ExpectNonEmptyPlan` is true.'
time: 2026-02-10T15:46:02.221648-05:00
custom:
Issue: "602"
3 changes: 2 additions & 1 deletion .github/workflows/ci-go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,12 @@ jobs:
with:
go-version-file: 'go.mod'
- run: go mod download
- uses: golangci/golangci-lint-action@e7fa5ac41e1cf5b7d48e45e42232ce7ada589601 # v9.1.0
- uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0
test:
name: test (Go ${{ matrix.go-version }} / TF ${{ matrix.terraform }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
go-version: [ '1.25', '1.24' ]
terraform: ${{ fromJSON(vars.TF_VERSIONS_PROTOCOL_V5) }}
Expand Down
10 changes: 5 additions & 5 deletions helper/resource/testcase_providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,26 +20,26 @@ func (c TestCase) providerConfig(_ context.Context, skipProviderBlock bool) stri
// is being used and not the others, but leaving it here just in case
// it does have a special purpose that wasn't being unit tested prior.
for name := range c.Providers {
providerBlocks.WriteString(fmt.Sprintf("provider %q {}\n", name))
fmt.Fprintf(&providerBlocks, "provider %q {}\n", name)
}

for name, externalProvider := range c.ExternalProviders {
if !skipProviderBlock {
providerBlocks.WriteString(fmt.Sprintf("provider %q {}\n", name))
fmt.Fprintf(&providerBlocks, "provider %q {}\n", name)
}

if externalProvider.Source == "" && externalProvider.VersionConstraint == "" {
continue
}

requiredProviderBlocks.WriteString(fmt.Sprintf(" %s = {\n", name))
fmt.Fprintf(&requiredProviderBlocks, " %s = {\n", name)

if externalProvider.Source != "" {
requiredProviderBlocks.WriteString(fmt.Sprintf(" source = %q\n", externalProvider.Source))
fmt.Fprintf(&requiredProviderBlocks, " source = %q\n", externalProvider.Source)
}

if externalProvider.VersionConstraint != "" {
requiredProviderBlocks.WriteString(fmt.Sprintf(" version = %q\n", externalProvider.VersionConstraint))
fmt.Fprintf(&requiredProviderBlocks, " version = %q\n", externalProvider.VersionConstraint)
}

requiredProviderBlocks.WriteString(" }\n")
Expand Down
20 changes: 17 additions & 3 deletions helper/resource/testing_new_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,17 @@ func testStepNewConfig(ctx context.Context, t testing.T, c TestCase, wd *plugint
if err != nil {
return fmt.Errorf("Error retrieving pre-apply state: %w", err)
}

// Create Destroy Plan
err = runProviderCommand(ctx, t, wd, providers, func() error {
var opts []tfexec.PlanOption
opts = append(opts, tfexec.Destroy(true))

return wd.CreatePlan(ctx, opts...)
})
if err != nil {
return fmt.Errorf("Error running destroy plan after step.Check shimmed state was retrieved: %w", err)
}
}

// Apply the diff, creating real resources
Expand Down Expand Up @@ -256,8 +267,11 @@ func testStepNewConfig(ctx context.Context, t testing.T, c TestCase, wd *plugint

// do a plan
err = runProviderCommand(ctx, t, wd, providers, func() error {
opts := []tfexec.PlanOption{
tfexec.Refresh(false),
var opts []tfexec.PlanOption
if refreshAfterApply {
opts = append(opts, tfexec.Refresh(true))
} else {
opts = append(opts, tfexec.Refresh(false))
}
if step.Destroy {
opts = append(opts, tfexec.Destroy(true))
Expand Down Expand Up @@ -457,7 +471,7 @@ func testStepNewConfig(ctx context.Context, t testing.T, c TestCase, wd *plugint
logging.HelperResourceDebug(ctx, "Called TestCase PostApplyFunc")
}

if refreshAfterApply && !step.Destroy && !step.PlanOnly {
if refreshAfterApply && !step.Destroy && !step.PlanOnly && !step.ExpectNonEmptyPlan {
if len(c.Steps) > stepIndex+1 {
// If the next step is a refresh, then we have no need to refresh here
if !c.Steps[stepIndex+1].RefreshState {
Expand Down
56 changes: 56 additions & 0 deletions helper/resource/testing_new_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/hashicorp/terraform-plugin-testing/internal/testing/testsdk/resource"
"github.com/hashicorp/terraform-plugin-testing/plancheck"
"github.com/hashicorp/terraform-plugin-testing/statecheck"
"github.com/hashicorp/terraform-plugin-testing/terraform"
"github.com/hashicorp/terraform-plugin-testing/tfversion"
)

Expand Down Expand Up @@ -906,3 +907,58 @@ func Test_PostApplyFunc_Called(t *testing.T) {
t.Error("expected ConfigStateChecks spy1 to be called at least once")
}
}

// This regression test ensures that the combination of Config, Destroy, and Check never result in
// a "Saved Plan is Stale" error message, which occurs when the state serial does not match the plan.
//
// This can occur when the refresh that is only done to produce the shimmed "Check" state produces a new state serial.
// Running a fresh plan after refreshing solves that issue, which was introduced in: https://github.com/hashicorp/terraform-plugin-testing/pull/602
func Test_Destroy_Checks_Avoid_Stale_Plan(t *testing.T) {
t.Parallel()

UnitTest(t, TestCase{
TerraformVersionChecks: []tfversion.TerraformVersionCheck{
tfversion.SkipBelow(tfversion.Version1_0_0), // ProtoV6ProviderFactories
},
ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){
"test": providerserver.NewProviderServer(testprovider.Provider{
Resources: map[string]testprovider.Resource{
"test_resource": {
CreateResponse: &resource.CreateResponse{
NewState: tftypes.NewValue(
tftypes.Object{
AttributeTypes: map[string]tftypes.Type{
"id": tftypes.String,
},
},
map[string]tftypes.Value{
"id": tftypes.NewValue(tftypes.String, "test"),
},
),
},
SchemaResponse: &resource.SchemaResponse{
Schema: &tfprotov6.Schema{
Block: &tfprotov6.SchemaBlock{
Attributes: []*tfprotov6.SchemaAttribute{
{
Name: "id",
Type: tftypes.String,
Computed: true,
},
},
},
},
},
},
},
}),
},
Steps: []TestStep{
{
Config: `resource "test_resource" "test" {}`,
Destroy: true,
Check: func(s *terraform.State) error { return nil },
},
},
})
}
12 changes: 6 additions & 6 deletions helper/resource/testing_new_import_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -423,11 +423,11 @@ func appendImportBlock(config teststep.Config, resourceName string, importID str

func appendImportBlockWithIdentity(config teststep.Config, resourceName string, identityValues map[string]any) teststep.Config {
configBuilder := strings.Builder{}
configBuilder.WriteString(fmt.Sprintf(``+"\n"+
fmt.Fprintf(&configBuilder, ``+"\n"+
`import {`+"\n"+
` to = %s`+"\n"+
` identity = {`+"\n",
resourceName))
resourceName)

for k, v := range identityValues {
// It's valid for identity attributes to be null, we can just omit it from config
Expand All @@ -437,20 +437,20 @@ func appendImportBlockWithIdentity(config teststep.Config, resourceName string,

switch v := v.(type) {
case bool:
configBuilder.WriteString(fmt.Sprintf(` %q = %t`+"\n", k, v))
fmt.Fprintf(&configBuilder, ` %q = %t`+"\n", k, v)

case []any:
var quotedV []string
for _, v := range v {
quotedV = append(quotedV, fmt.Sprintf(`%q`, v))
}
configBuilder.WriteString(fmt.Sprintf(` %q = [%s]`+"\n", k, strings.Join(quotedV, ", ")))
fmt.Fprintf(&configBuilder, ` %q = [%s]`+"\n", k, strings.Join(quotedV, ", "))

case json.Number:
configBuilder.WriteString(fmt.Sprintf(` %q = %s`+"\n", k, v))
fmt.Fprintf(&configBuilder, ` %q = %s`+"\n", k, v)

case string:
configBuilder.WriteString(fmt.Sprintf(` %q = %q`+"\n", k, v))
fmt.Fprintf(&configBuilder, ` %q = %q`+"\n", k, v)

default:
panic(fmt.Sprintf("unexpected type %T for identity value %q", v, k))
Expand Down
24 changes: 12 additions & 12 deletions helper/resource/teststep_providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,21 +71,21 @@ func (s TestStep) providerConfig(_ context.Context, skipProviderBlock bool, tfVe

for name, externalProvider := range s.ExternalProviders {
if !skipProviderBlock {
providerBlocks.WriteString(fmt.Sprintf("provider %q {}\n", name))
fmt.Fprintf(&providerBlocks, "provider %q {}\n", name)
}

if externalProvider.Source == "" && externalProvider.VersionConstraint == "" {
continue
}

requiredProviderBlocks.WriteString(fmt.Sprintf(" %s = {\n", name))
fmt.Fprintf(&requiredProviderBlocks, " %s = {\n", name)

if externalProvider.Source != "" {
requiredProviderBlocks.WriteString(fmt.Sprintf(" source = %q\n", externalProvider.Source))
fmt.Fprintf(&requiredProviderBlocks, " source = %q\n", externalProvider.Source)
}

if externalProvider.VersionConstraint != "" {
requiredProviderBlocks.WriteString(fmt.Sprintf(" version = %q\n", externalProvider.VersionConstraint))
fmt.Fprintf(&requiredProviderBlocks, " version = %q\n", externalProvider.VersionConstraint)
}

requiredProviderBlocks.WriteString(" }\n")
Expand Down Expand Up @@ -154,30 +154,30 @@ func (s TestStep) providerConfigTestCase(_ context.Context, skipProviderBlock bo
// is being used and not the others, but leaving it here just in case
// it does have a special purpose that wasn't being unit tested prior.
for name := range providerNames {
providerBlocks.WriteString(fmt.Sprintf("provider %q {}\n", name))
fmt.Fprintf(&providerBlocks, "provider %q {}\n", name)

requiredProviderBlocks.WriteString(fmt.Sprintf(" %s = {\n", name))
fmt.Fprintf(&requiredProviderBlocks, " %s = {\n", name)

requiredProviderBlocks.WriteString(" }\n")
}

for name, externalProvider := range testCase.ExternalProviders {
if !skipProviderBlock {
providerBlocks.WriteString(fmt.Sprintf("provider %q {}\n", name))
fmt.Fprintf(&providerBlocks, "provider %q {}\n", name)
}

if externalProvider.Source == "" && externalProvider.VersionConstraint == "" {
continue
}

requiredProviderBlocks.WriteString(fmt.Sprintf(" %s = {\n", name))
fmt.Fprintf(&requiredProviderBlocks, " %s = {\n", name)

if externalProvider.Source != "" {
requiredProviderBlocks.WriteString(fmt.Sprintf(" source = %q\n", externalProvider.Source))
fmt.Fprintf(&requiredProviderBlocks, " source = %q\n", externalProvider.Source)
}

if externalProvider.VersionConstraint != "" {
requiredProviderBlocks.WriteString(fmt.Sprintf(" version = %q\n", externalProvider.VersionConstraint))
fmt.Fprintf(&requiredProviderBlocks, " version = %q\n", externalProvider.VersionConstraint)
}

requiredProviderBlocks.WriteString(" }\n")
Expand Down Expand Up @@ -250,8 +250,8 @@ func addTerraformBlockSource(name, config string) string {

var providerBlocks strings.Builder

providerBlocks.WriteString(fmt.Sprintf(" %s = {\n", name))
providerBlocks.WriteString(fmt.Sprintf(" source = %q\n", getProviderAddr(name)))
fmt.Fprintf(&providerBlocks, " %s = {\n", name)
fmt.Fprintf(&providerBlocks, " source = %q\n", getProviderAddr(name))
providerBlocks.WriteString(" }\n")

return providerBlocks.String()
Expand Down
32 changes: 16 additions & 16 deletions terraform/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -803,7 +803,7 @@ func (s *State) String() string {
continue
}

buf.WriteString(fmt.Sprintf("module.%s:\n", strings.Join(m.Path[1:], ".")))
fmt.Fprintf(&buf, "module.%s:\n", strings.Join(m.Path[1:], "."))

s := bufio.NewScanner(strings.NewReader(mStr))
for s.Scan() {
Expand All @@ -812,7 +812,7 @@ func (s *State) String() string {
text = " " + text
}

buf.WriteString(fmt.Sprintf("%s\n", text))
fmt.Fprintf(&buf, "%s\n", text)
}
}

Expand Down Expand Up @@ -1130,10 +1130,10 @@ func (m *ModuleState) String() string {
deposedStr = fmt.Sprintf(" (%d deposed)", len(rs.Deposed))
}

buf.WriteString(fmt.Sprintf("%s:%s%s\n", k, taintStr, deposedStr))
buf.WriteString(fmt.Sprintf(" ID = %s\n", id))
fmt.Fprintf(&buf, "%s:%s%s\n", k, taintStr, deposedStr)
fmt.Fprintf(&buf, " ID = %s\n", id)
if rs.Provider != "" {
buf.WriteString(fmt.Sprintf(" provider = %s\n", rs.Provider))
fmt.Fprintf(&buf, " provider = %s\n", rs.Provider)
}

var attributes map[string]string
Expand All @@ -1153,21 +1153,21 @@ func (m *ModuleState) String() string {

for _, ak := range attrKeys {
av := attributes[ak]
buf.WriteString(fmt.Sprintf(" %s = %s\n", ak, av))
fmt.Fprintf(&buf, " %s = %s\n", ak, av)
}

for idx, t := range rs.Deposed {
taintStr := ""
if t.Tainted {
taintStr = " (tainted)"
}
buf.WriteString(fmt.Sprintf(" Deposed ID %d = %s%s\n", idx+1, t.ID, taintStr))
fmt.Fprintf(&buf, " Deposed ID %d = %s%s\n", idx+1, t.ID, taintStr)
}

if len(rs.Dependencies) > 0 {
buf.WriteString("\n Dependencies:\n")
for _, dep := range rs.Dependencies {
buf.WriteString(fmt.Sprintf(" %s\n", dep))
fmt.Fprintf(&buf, " %s\n", dep)
}
}
}
Expand All @@ -1186,9 +1186,9 @@ func (m *ModuleState) String() string {
v := m.Outputs[k]
switch vTyped := v.Value.(type) {
case string:
buf.WriteString(fmt.Sprintf("%s = %s\n", k, vTyped))
fmt.Fprintf(&buf, "%s = %s\n", k, vTyped)
case []interface{}:
buf.WriteString(fmt.Sprintf("%s = %s\n", k, vTyped))
fmt.Fprintf(&buf, "%s = %s\n", k, vTyped)
case map[string]interface{}:
var mapKeys []string
for key := range vTyped {
Expand All @@ -1199,11 +1199,11 @@ func (m *ModuleState) String() string {
var mapBuf bytes.Buffer
mapBuf.WriteString("{")
for _, key := range mapKeys {
mapBuf.WriteString(fmt.Sprintf("%s:%s ", key, vTyped[key]))
fmt.Fprintf(&mapBuf, "%s:%s ", key, vTyped[key])
}
mapBuf.WriteString("}")

buf.WriteString(fmt.Sprintf("%s = %s\n", k, mapBuf.String()))
fmt.Fprintf(&buf, "%s = %s\n", k, mapBuf.String())
}
}
}
Expand Down Expand Up @@ -1438,7 +1438,7 @@ func (s *ResourceState) String() string {
defer s.Unlock()

var buf bytes.Buffer
buf.WriteString(fmt.Sprintf("Type = %s", s.Type))
fmt.Fprintf(&buf, "Type = %s", s.Type)
return buf.String()
}

Expand Down Expand Up @@ -1748,7 +1748,7 @@ func (s *InstanceState) String() string {
return notCreated
}

buf.WriteString(fmt.Sprintf("ID = %s\n", s.ID))
fmt.Fprintf(&buf, "ID = %s\n", s.ID)

attributes := s.Attributes
attrKeys := make([]string, 0, len(attributes))
Expand All @@ -1763,10 +1763,10 @@ func (s *InstanceState) String() string {

for _, ak := range attrKeys {
av := attributes[ak]
buf.WriteString(fmt.Sprintf("%s = %s\n", ak, av))
fmt.Fprintf(&buf, "%s = %s\n", ak, av)
}

buf.WriteString(fmt.Sprintf("Tainted = %t\n", s.Tainted))
fmt.Fprintf(&buf, "Tainted = %t\n", s.Tainted)

return buf.String()
}
Expand Down
Loading