Skip to content

Commit a72064c

Browse files
authored
plannable import: support step.ConfigExact = [true|false] for all config sources (#494)
1 parent e801590 commit a72064c

17 files changed

+355
-67
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
kind: NOTES
2+
body: 'ImportState: extend auto-generated import blocks to work with `ConfigFile` and `ConfigDirectory`; adds `ConfigExact` flag to opt out'
3+
time: 2025-05-06T12:16:18.375025-04:00
4+
custom:
5+
Issue: "494"

helper/resource/importstate/import_block_as_first_step_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ func TestImportBlock_AsFirstStep(t *testing.T) {
4848
id = "westeurope/somevalue"
4949
}
5050
`,
51+
ImportStateConfigExact: true,
5152
ImportPlanChecks: r.ImportPlanChecks{
5253
PreApply: []plancheck.PlanCheck{
5354
plancheck.ExpectResourceAction("examplecloud_container.test", plancheck.ResourceActionNoop),

helper/resource/importstate/import_block_in_config_directory_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,35 @@ func TestImportBlock_InConfigDirectory(t *testing.T) {
4343
},
4444
})
4545
}
46+
47+
func TestImportBlock_InConfigDirectory_ConfigExactTrue(t *testing.T) {
48+
t.Parallel()
49+
50+
r.UnitTest(t, r.TestCase{
51+
TerraformVersionChecks: []tfversion.TerraformVersionCheck{
52+
tfversion.SkipBelow(tfversion.Version1_5_0), // ImportBlockWithID requires Terraform 1.5.0 or later
53+
},
54+
ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){
55+
"examplecloud": providerserver.NewProviderServer(testprovider.Provider{
56+
Resources: map[string]testprovider.Resource{
57+
"examplecloud_container": examplecloudResource(),
58+
},
59+
}),
60+
},
61+
Steps: []r.TestStep{
62+
{
63+
ConfigDirectory: config.StaticDirectory(`testdata/1`),
64+
},
65+
{
66+
ResourceName: "examplecloud_container.test",
67+
ImportState: true,
68+
ImportStateKind: r.ImportBlockWithID,
69+
70+
// This content includes an import block with an ID so we will
71+
// use the exact content
72+
ConfigDirectory: config.StaticDirectory(`testdata/2_with_exact_import_config`),
73+
ImportStateConfigExact: true,
74+
},
75+
},
76+
})
77+
}

helper/resource/importstate/import_block_in_config_file_test.go

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ func TestImportBlock_InConfigFile(t *testing.T) {
3838
ResourceName: "examplecloud_container.test",
3939
ImportState: true,
4040
ImportStateKind: r.ImportBlockWithID,
41-
ConfigFile: config.StaticFile(`testdata/2/examplecloud_container_import.tf`),
41+
ConfigFile: config.StaticFile(`testdata/2/examplecloud_container.tf`),
4242
},
4343
},
4444
})
@@ -66,7 +66,39 @@ func TestImportBlock_WithResourceIdentity_InConfigFile(t *testing.T) {
6666
ResourceName: "examplecloud_container.test",
6767
ImportState: true,
6868
ImportStateKind: r.ImportBlockWithResourceIdentity,
69-
ConfigFile: config.StaticFile(`testdata/examplecloud_container_import_with_identity.tf`),
69+
ConfigFile: config.StaticFile(`testdata/2/examplecloud_container.tf`),
70+
},
71+
},
72+
})
73+
}
74+
75+
func TestImportBlock_InConfigFile_ConfigExactTrue(t *testing.T) {
76+
t.Parallel()
77+
78+
r.UnitTest(t, r.TestCase{
79+
TerraformVersionChecks: []tfversion.TerraformVersionCheck{
80+
tfversion.SkipBelow(tfversion.Version1_5_0), // ImportBlockWithID requires Terraform 1.5.0 or later
81+
},
82+
ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){
83+
"examplecloud": providerserver.NewProviderServer(testprovider.Provider{
84+
Resources: map[string]testprovider.Resource{
85+
"examplecloud_container": examplecloudResource(),
86+
},
87+
}),
88+
},
89+
Steps: []r.TestStep{
90+
{
91+
ConfigFile: config.StaticFile(`testdata/1/examplecloud_container.tf`),
92+
},
93+
{
94+
ResourceName: "examplecloud_container.test",
95+
ImportState: true,
96+
ImportStateKind: r.ImportBlockWithID,
97+
98+
// This content includes an import block with an ID so we will
99+
// use the exact content
100+
ConfigFile: config.StaticFile(`testdata/examplecloud_container_with_exact_import_config_with_id.tf`),
101+
ImportStateConfigExact: true,
70102
},
71103
},
72104
})

helper/resource/importstate/import_block_with_id_test.go

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,11 @@ func TestImportBlock_WithID_ExpectError(t *testing.T) {
8989
id = "westeurope/somevalue"
9090
}
9191
`,
92-
ResourceName: "examplecloud_container.test",
93-
ImportState: true,
94-
ImportStateKind: r.ImportBlockWithID,
95-
ExpectError: regexp.MustCompile(`importing resource examplecloud_container.test: expected a no-op import operation, got.*\["update"\] action with plan(.?)`),
92+
ResourceName: "examplecloud_container.test",
93+
ImportState: true,
94+
ImportStateKind: r.ImportBlockWithID,
95+
ImportStateConfigExact: true,
96+
ExpectError: regexp.MustCompile(`importing resource examplecloud_container.test: expected a no-op import operation, got.*\["update"\] action with plan(.?)`),
9697
},
9798
},
9899
})
@@ -295,9 +296,10 @@ func TestImportBlock_WithID_WithBlankOptionalAttribute_GeneratesCorrectPlan(t *t
295296
id = "sometestid"
296297
297298
}`,
298-
ResourceName: "examplecloud_container.test",
299-
ImportState: true,
300-
ImportStateKind: r.ImportBlockWithID,
299+
ResourceName: "examplecloud_container.test",
300+
ImportState: true,
301+
ImportStateKind: r.ImportBlockWithID,
302+
ImportStateConfigExact: true,
301303
},
302304
},
303305
})
@@ -416,10 +418,11 @@ import {
416418
Config: config,
417419
},
418420
{
419-
ImportState: true,
420-
ImportStateKind: r.ImportBlockWithID,
421-
Config: configWithImportBlock,
422-
ResourceName: "random_string.mystery_message",
421+
ImportState: true,
422+
ImportStateKind: r.ImportBlockWithID,
423+
ImportStateConfigExact: true,
424+
Config: configWithImportBlock,
425+
ResourceName: "random_string.mystery_message",
423426
ImportPlanChecks: r.ImportPlanChecks{
424427
PreApply: []plancheck.PlanCheck{
425428
plancheck.ExpectKnownValue(
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Copyright (c) HashiCorp, Inc.
2+
# SPDX-License-Identifier: MPL-2.0
3+
4+
resource "examplecloud_container" "test" {
5+
name = "somevalue"
6+
location = "westeurope"
7+
}

helper/resource/importstate/testdata/examplecloud_container_import_with_identity.tf renamed to helper/resource/importstate/testdata/examplecloud_container_with_exact_import_config_with_id.tf

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,5 @@ resource "examplecloud_container" "test" {
88

99
import {
1010
to = examplecloud_container.test
11-
identity = {
12-
id = "examplecloud_container.test"
13-
}
11+
id = "examplecloud_container.test"
1412
}

helper/resource/testing.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,6 +576,16 @@ type TestStep struct {
576576
// otherwise an error will be returned.
577577
ConfigFile config.TestStepConfigFunc
578578

579+
// ImportStateConfigExact indicates that the test framework should use the exact
580+
// content of the Config, ConfigFile, or ConfigDirectory inputs and should
581+
// not modify it at test run time.
582+
//
583+
// The default is false. At test run time, the test framework will generate
584+
// specific kinds of configuration, such as import blocks, and append them
585+
// to the given Config, ConfigFile, or ConfigDirectory inputs. Using this
586+
// default improves test readability and removes duplication of setup.
587+
ImportStateConfigExact bool
588+
579589
// ConfigVariables is a map defining variables for use in conjunction
580590
// with Terraform configuration. If this map is populated then it
581591
// will be used to assemble an *.auto.tfvars.json which will be

helper/resource/testing_new_import_state.go

Lines changed: 37 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -105,34 +105,36 @@ func testStepNewImportState(ctx context.Context, t testing.T, helper *plugintest
105105
}
106106
}
107107

108+
var inlineConfig string
109+
if step.Config != "" {
110+
inlineConfig = step.Config
111+
} else {
112+
inlineConfig = cfgRaw
113+
}
108114
testStepConfigRequest := config.TestStepConfigRequest{
109115
StepNumber: stepNumber,
110116
TestName: t.Name(),
111117
}
112118
testStepConfig := teststep.Configuration(teststep.PrepareConfigurationRequest{
113119
Directory: step.ConfigDirectory,
114120
File: step.ConfigFile,
115-
Raw: step.Config,
121+
Raw: inlineConfig,
116122
TestStepConfigRequest: testStepConfigRequest,
117123
}.Exec())
118124

119-
if testStepConfig == nil {
120-
logging.HelperResourceTrace(ctx, "Using prior TestStep Config for import")
121-
importConfig := cfgRaw
125+
switch {
126+
case step.ImportStateConfigExact:
127+
break
122128

123-
if kind.plannable() && kind.resourceIdentity() {
124-
importConfig = appendImportBlockWithIdentity(importConfig, resourceName, priorIdentityValues)
125-
} else if kind.plannable() {
126-
importConfig = appendImportBlock(importConfig, resourceName, importId)
127-
}
129+
case kind.plannable() && kind.resourceIdentity():
130+
testStepConfig = appendImportBlockWithIdentity(testStepConfig, resourceName, priorIdentityValues)
128131

129-
testStepConfig = teststep.Configuration(teststep.PrepareConfigurationRequest{
130-
Raw: importConfig,
131-
TestStepConfigRequest: testStepConfigRequest,
132-
}.Exec())
133-
if testStepConfig == nil {
134-
t.Fatal("Cannot import state with no specified config")
135-
}
132+
case kind.plannable():
133+
testStepConfig = appendImportBlock(testStepConfig, resourceName, importId)
134+
}
135+
136+
if testStepConfig == nil {
137+
t.Fatal("Cannot import state with no specified config")
136138
}
137139

138140
var workingDir *plugintest.WorkingDir
@@ -424,51 +426,51 @@ func testImportCommand(ctx context.Context, t testing.T, workingDir *plugintest.
424426
return nil
425427
}
426428

427-
func appendImportBlock(config string, resourceName string, importID string) string {
428-
return config + fmt.Sprintf(``+"\n"+
429-
`import {`+"\n"+
430-
` to = %s`+"\n"+
431-
` id = %q`+"\n"+
432-
`}`,
433-
resourceName, importID)
429+
func appendImportBlock(config teststep.Config, resourceName string, importID string) teststep.Config {
430+
return config.Append(
431+
fmt.Sprintf(``+"\n"+
432+
`import {`+"\n"+
433+
` to = %s`+"\n"+
434+
` id = %q`+"\n"+
435+
`}`,
436+
resourceName, importID))
434437
}
435438

436-
func appendImportBlockWithIdentity(config string, resourceName string, identityValues map[string]any) string {
437-
configBuilder := config
438-
configBuilder += fmt.Sprintf(``+"\n"+
439+
func appendImportBlockWithIdentity(config teststep.Config, resourceName string, identityValues map[string]any) teststep.Config {
440+
configBuilder := strings.Builder{}
441+
configBuilder.WriteString(fmt.Sprintf(``+"\n"+
439442
`import {`+"\n"+
440443
` to = %s`+"\n"+
441444
` identity = {`+"\n",
442-
resourceName)
445+
resourceName))
443446

444447
for k, v := range identityValues {
445448
switch v := v.(type) {
446449
case bool:
447-
configBuilder += fmt.Sprintf(` %q = %t`+"\n", k, v)
450+
configBuilder.WriteString(fmt.Sprintf(` %q = %t`+"\n", k, v))
448451

449452
case []any:
450453
var quotedV []string
451454
for _, v := range v {
452455
quotedV = append(quotedV, fmt.Sprintf(`%q`, v))
453456
}
454-
configBuilder += fmt.Sprintf(` %q = [%s]`+"\n", k, strings.Join(quotedV, ", "))
457+
configBuilder.WriteString(fmt.Sprintf(` %q = [%s]`+"\n", k, strings.Join(quotedV, ", ")))
455458

456459
case json.Number:
457-
configBuilder += fmt.Sprintf(` %q = %s`+"\n", k, v)
460+
configBuilder.WriteString(fmt.Sprintf(` %q = %s`+"\n", k, v))
458461

459462
case string:
460-
configBuilder += fmt.Sprintf(` %q = %q`+"\n", k, v)
463+
configBuilder.WriteString(fmt.Sprintf(` %q = %q`+"\n", k, v))
461464

462465
default:
463466
panic(fmt.Sprintf("unexpected type %T for identity value %q", v, k))
464467
}
465468
}
466469

467-
configBuilder += `` +
468-
` }` + "\n" +
469-
`}` + "\n"
470+
configBuilder.WriteString(` }` + "\n")
471+
configBuilder.WriteString(`}` + "\n")
470472

471-
return configBuilder
473+
return config.Append(configBuilder.String())
472474
}
473475

474476
func importStatePreconditions(t testing.T, helper *plugintest.Helper, step TestStep) error {

internal/teststep/config.go

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ type Config interface {
4545
HasProviderBlock(context.Context) (bool, error)
4646
HasTerraformBlock(context.Context) (bool, error)
4747
Write(context.Context, string) error
48+
Append(string) Config
4849
}
4950

5051
// PrepareConfigurationRequest is used to simplify the generation of
@@ -151,7 +152,7 @@ func copyFiles(path string, dstPath string) error {
151152
if info.IsDir() {
152153
continue
153154
} else {
154-
err = copyFile(srcPath, dstPath)
155+
_, err = copyFile(srcPath, dstPath)
155156

156157
if err != nil {
157158
return err
@@ -164,19 +165,19 @@ func copyFiles(path string, dstPath string) error {
164165

165166
// copyFile accepts a path to a file and a destination,
166167
// copying the file from path to destination.
167-
func copyFile(path string, dstPath string) error {
168+
func copyFile(path string, dstPath string) (string, error) {
168169
srcF, err := os.Open(path)
169170

170171
if err != nil {
171-
return err
172+
return "", err
172173
}
173174

174175
defer srcF.Close()
175176

176177
di, err := os.Stat(dstPath)
177178

178179
if err != nil {
179-
return err
180+
return "", err
180181
}
181182

182183
if di.IsDir() {
@@ -187,12 +188,28 @@ func copyFile(path string, dstPath string) error {
187188
dstF, err := os.Create(dstPath)
188189

189190
if err != nil {
190-
return err
191+
return "", err
191192
}
192193

193194
defer dstF.Close()
194195

195196
if _, err := io.Copy(dstF, srcF); err != nil {
197+
return "", err
198+
}
199+
200+
return dstPath, nil
201+
}
202+
203+
// appendToFile accepts a path to a file and a string,
204+
// appending the file from path to destination.
205+
func appendToFile(path string, content string) error {
206+
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, os.ModeAppend)
207+
if err != nil {
208+
return err
209+
}
210+
defer f.Close()
211+
212+
if _, err := io.WriteString(f, content); err != nil {
196213
return err
197214
}
198215

0 commit comments

Comments
 (0)