diff --git a/.copywrite.hcl b/.copywrite.hcl index 514c3e4b1..ffb0638a5 100644 --- a/.copywrite.hcl +++ b/.copywrite.hcl @@ -5,6 +5,8 @@ project { copyright_year = 2014 header_ignore = [ + "META.d/**/*.yaml", + # changie tooling configuration and CHANGELOG entries (prose) ".changes/unreleased/**", ".changie.yaml", diff --git a/META.d/_summary.yaml b/META.d/_summary.yaml new file mode 100644 index 000000000..56ab752a7 --- /dev/null +++ b/META.d/_summary.yaml @@ -0,0 +1,10 @@ +--- +schema: 1.1 + +partition: tf-ecosystem + +summary: + owner: team-tf-core-plugins + description: | + Module for testing Terraform providers + visibility: public diff --git a/helper/resource/importstate/examplecloud_test.go b/helper/resource/importstate/examplecloud_test.go index 5e7ab4179..e1b1462c8 100644 --- a/helper/resource/importstate/examplecloud_test.go +++ b/helper/resource/importstate/examplecloud_test.go @@ -448,7 +448,7 @@ func examplecloudResourceWithEveryIdentitySchemaType() testprovider.Resource { { Name: "cabinet", Type: tftypes.String, - RequiredForImport: true, + OptionalForImport: true, }, { Name: "unit", diff --git a/helper/resource/importstate/import_block_in_config_directory_test.go b/helper/resource/importstate/import_block_in_config_directory_test.go index df6feecba..da5e962b1 100644 --- a/helper/resource/importstate/import_block_in_config_directory_test.go +++ b/helper/resource/importstate/import_block_in_config_directory_test.go @@ -35,10 +35,40 @@ func TestImportBlock_InConfigDirectory(t *testing.T) { ConfigDirectory: config.StaticDirectory(`testdata/1`), }, { - ResourceName: "examplecloud_container.test", - ImportState: true, - ImportStateKind: r.ImportBlockWithID, - ConfigDirectory: config.StaticDirectory(`testdata/2`), + ResourceName: "examplecloud_container.test", + ImportState: true, + ImportStateKind: r.ImportBlockWithID, + ImportStateReadOnlyConfig: true, // TODO: naming + ConfigDirectory: config.StaticDirectory(`testdata/2`), + }, + }, + }) +} + +func TestImportBlock_InConfigDirectory_Writeable(t *testing.T) { + t.Parallel() + + r.UnitTest(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_5_0), // ImportBlockWithID requires Terraform 1.5.0 or later + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "examplecloud": providerserver.NewProviderServer(testprovider.Provider{ + Resources: map[string]testprovider.Resource{ + "examplecloud_container": examplecloudResource(), + }, + }), + }, + Steps: []r.TestStep{ + { + ConfigDirectory: config.StaticDirectory(`testdata/1`), + }, + { + ResourceName: "examplecloud_container.test", + ImportState: true, + ImportStateKind: r.ImportBlockWithID, + ImportStateReadOnlyConfig: false, + ConfigDirectory: config.StaticDirectory(`testdata/writeable-config-directory`), }, }, }) diff --git a/helper/resource/importstate/import_block_with_resource_identity_test.go b/helper/resource/importstate/import_block_with_resource_identity_test.go index 77e8a3cd7..63c2d4e58 100644 --- a/helper/resource/importstate/import_block_with_resource_identity_test.go +++ b/helper/resource/importstate/import_block_with_resource_identity_test.go @@ -112,3 +112,36 @@ func TestImportBlock_WithResourceIdentity_RequiresVersion1_12_0(t *testing.T) { }, }) } + +func TestImportBlock_WithResourceIdentity_WithOptionalForImportAttributeUnset(t *testing.T) { + t.Parallel() + + r.UnitTest(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_12_0), // ImportBlockWithResourceIdentity requires Terraform 1.12.0 or later + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "examplecloud": providerserver.NewProviderServer(testprovider.Provider{ + Resources: map[string]testprovider.Resource{ + "examplecloud_container": examplecloudResourceWithEveryIdentitySchemaType(), + }, + }), + }, + Steps: []r.TestStep{ + { + Config: ` + resource "examplecloud_container" "test" { + // cabinet = "A1" + unit = 14 + tags = ["storage", "fast"] + active = true + }`, + }, + { + ResourceName: "examplecloud_container.test", + ImportState: true, + ImportStateKind: r.ImportBlockWithResourceIdentity, + }, + }, + }) +} diff --git a/helper/resource/importstate/testdata/writeable-config-directory/examplecloud_container_import_with_identity.tf b/helper/resource/importstate/testdata/writeable-config-directory/examplecloud_container_import_with_identity.tf new file mode 100644 index 000000000..ccfb698e6 --- /dev/null +++ b/helper/resource/importstate/testdata/writeable-config-directory/examplecloud_container_import_with_identity.tf @@ -0,0 +1,7 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + +resource "examplecloud_container" "test" { + name = "somevalue" + location = "westeurope" +} diff --git a/helper/resource/importstate/testdata/writeable-config-file/examplecloud_container_import_with_identity.tf b/helper/resource/importstate/testdata/writeable-config-file/examplecloud_container_import_with_identity.tf new file mode 100644 index 000000000..9412afb97 --- /dev/null +++ b/helper/resource/importstate/testdata/writeable-config-file/examplecloud_container_import_with_identity.tf @@ -0,0 +1,14 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + +resource "examplecloud_container" "test" { + name = "somevalue" + location = "westeurope" +} + +import { + to = examplecloud_container.test + identity = { + id = "examplecloud_container.test" + } +} diff --git a/helper/resource/importstate/types_test.go b/helper/resource/importstate/types_test.go index 8532b40da..71867677e 100644 --- a/helper/resource/importstate/types_test.go +++ b/helper/resource/importstate/types_test.go @@ -25,6 +25,14 @@ func OptionalComputedListAttribute(name string, elementType tftypes.Type) *tfpro } } +func OptionalListAttribute(name string, elementType tftypes.Type) *tfprotov6.SchemaAttribute { + return &tfprotov6.SchemaAttribute{ + Name: name, + Type: tftypes.List{ElementType: elementType}, + Optional: true, + } +} + func RequiredListAttribute(name string, elementType tftypes.Type) *tfprotov6.SchemaAttribute { return &tfprotov6.SchemaAttribute{ Name: name, diff --git a/helper/resource/testing.go b/helper/resource/testing.go index 0cfba4991..5ee6f74d6 100644 --- a/helper/resource/testing.go +++ b/helper/resource/testing.go @@ -666,6 +666,10 @@ type TestStep struct { ImportStateKind ImportStateKind + // ImportStateReadOnlyConfig indicates that the test framework should not + // modify ConfigFile or ConfigDirectory inputs to the test step. + ImportStateReadOnlyConfig bool + // ImportStateId is the ID to perform an ImportState operation with. // This is optional. If it isn't set, then the resource ID is automatically // determined by inspecting the state for ResourceName's ID. diff --git a/helper/resource/testing_new_import_state.go b/helper/resource/testing_new_import_state.go index 887707180..eba2cbe09 100644 --- a/helper/resource/testing_new_import_state.go +++ b/helper/resource/testing_new_import_state.go @@ -116,23 +116,40 @@ func testStepNewImportState(ctx context.Context, t testing.T, helper *plugintest TestStepConfigRequest: testStepConfigRequest, }.Exec()) - if testStepConfig == nil { + switch { + case testStepConfig == nil: logging.HelperResourceTrace(ctx, "Using prior TestStep Config for import") - importConfig := cfgRaw + + testStepConfig = teststep.Configuration(teststep.PrepareConfigurationRequest{ + Raw: cfgRaw, + TestStepConfigRequest: testStepConfigRequest, + }.Exec()) if kind.plannable() && kind.resourceIdentity() { - importConfig = appendImportBlockWithIdentity(importConfig, resourceName, priorIdentityValues) + testStepConfig = appendImportBlockWithIdentity(testStepConfig, resourceName, priorIdentityValues) } else if kind.plannable() { - importConfig = appendImportBlock(importConfig, resourceName, importId) + testStepConfig = appendImportBlock(testStepConfig, resourceName, importId) } - testStepConfig = teststep.Configuration(teststep.PrepareConfigurationRequest{ - Raw: importConfig, - TestStepConfigRequest: testStepConfigRequest, - }.Exec()) if testStepConfig == nil { t.Fatal("Cannot import state with no specified config") } + + case step.ImportStateReadOnlyConfig: + break + + case step.ConfigDirectory != nil: + // TODO: extract / DRY + + if kind.plannable() && kind.resourceIdentity() { + testStepConfig = appendImportBlockWithIdentity(testStepConfig, resourceName, priorIdentityValues) + } else if kind.plannable() { + testStepConfig = appendImportBlock(testStepConfig, resourceName, importId) + } + + case step.ConfigFile != nil: + // TODO: ship it + } var workingDir *plugintest.WorkingDir @@ -424,51 +441,52 @@ func testImportCommand(ctx context.Context, t testing.T, workingDir *plugintest. return nil } -func appendImportBlock(config string, resourceName string, importID string) string { - return config + fmt.Sprintf(``+"\n"+ - `import {`+"\n"+ - ` to = %s`+"\n"+ - ` id = %q`+"\n"+ - `}`, - resourceName, importID) +func appendImportBlock(config teststep.Config, resourceName string, importID string) teststep.Config { + return config.Append( + context.Background(), // TODO: remove + fmt.Sprintf(``+"\n"+ + `import {`+"\n"+ + ` to = %s`+"\n"+ + ` id = %q`+"\n"+ + `}`, + resourceName, importID)) } -func appendImportBlockWithIdentity(config string, resourceName string, identityValues map[string]any) string { - configBuilder := config - configBuilder += fmt.Sprintf(``+"\n"+ +func appendImportBlockWithIdentity(config teststep.Config, resourceName string, identityValues map[string]any) teststep.Config { + configBuilder := strings.Builder{} + configBuilder.WriteString(fmt.Sprintf(``+"\n"+ `import {`+"\n"+ ` to = %s`+"\n"+ ` identity = {`+"\n", - resourceName) + resourceName)) for k, v := range identityValues { switch v := v.(type) { case bool: - configBuilder += fmt.Sprintf(` %q = %t`+"\n", k, v) + configBuilder.WriteString(fmt.Sprintf(` %q = %t`+"\n", k, v)) case []any: var quotedV []string for _, v := range v { quotedV = append(quotedV, fmt.Sprintf(`%q`, v)) } - configBuilder += fmt.Sprintf(` %q = [%s]`+"\n", k, strings.Join(quotedV, ", ")) + configBuilder.WriteString(fmt.Sprintf(` %q = [%s]`+"\n", k, strings.Join(quotedV, ", "))) case json.Number: - configBuilder += fmt.Sprintf(` %q = %s`+"\n", k, v) + configBuilder.WriteString(fmt.Sprintf(` %q = %s`+"\n", k, v)) case string: - configBuilder += fmt.Sprintf(` %q = %q`+"\n", k, v) + configBuilder.WriteString(fmt.Sprintf(` %q = %q`+"\n", k, v)) default: panic(fmt.Sprintf("unexpected type %T for identity value %q", v, k)) } } - configBuilder += `` + - ` }` + "\n" + - `}` + "\n" + configBuilder.WriteString(` }` + "\n") + configBuilder.WriteString(`}` + "\n") - return configBuilder + return config.Append(context.Background(), configBuilder.String()) } func importStatePreconditions(t testing.T, helper *plugintest.Helper, step TestStep) error { diff --git a/internal/teststep/config.go b/internal/teststep/config.go index b81c264d9..8220d6389 100644 --- a/internal/teststep/config.go +++ b/internal/teststep/config.go @@ -45,6 +45,7 @@ type Config interface { HasProviderBlock(context.Context) (bool, error) HasTerraformBlock(context.Context) (bool, error) Write(context.Context, string) error + Append(context.Context, string) Config } // PrepareConfigurationRequest is used to simplify the generation of @@ -199,6 +200,22 @@ func copyFile(path string, dstPath string) error { return nil } +// appendToFile accepts a path to a file and a string, +// appending the file from path to destination. +func appendToFile(path string, content string) error { + f, err := os.OpenFile(path, os.O_APPEND, os.ModeAppend) + if err != nil { + return err + } + defer f.Close() + + if _, err := io.WriteString(f, content); err != nil { + return err + } + + return nil +} + // filesContains accepts a string representing a directory and a // regular expression. For each file that is found within the // directory fileContains func is called. Any nested directories diff --git a/internal/teststep/directory.go b/internal/teststep/directory.go index 0126e82aa..fd793cc15 100644 --- a/internal/teststep/directory.go +++ b/internal/teststep/directory.go @@ -5,14 +5,18 @@ package teststep import ( "context" + "fmt" + "hash/crc32" "os" "path/filepath" ) var _ Config = configurationDirectory{} +// not threadsafe type configurationDirectory struct { - directory string + directory string + generatedFiles map[string]string } // HasConfigurationFiles is used during validation to ensure that @@ -85,10 +89,43 @@ func (c configurationDirectory) Write(ctx context.Context, dest string) error { } err := copyFiles(configDirectory, dest) + if err != nil { + return err + } + err = c.writeGeneratedFiles(dest) if err != nil { return err } return nil } + +func (c configurationDirectory) Append(_ context.Context, config string) Config { + if c.generatedFiles == nil { + c.generatedFiles = make(map[string]string) + } + + checksum := crc32.Checksum([]byte(config), crc32.IEEETable) + filename := fmt.Sprintf("terraform_plugin_test_%d.tf", checksum) + + fmt.Println("Appending generated file:", filename) + c.generatedFiles[filename] = config + return c +} + +func (c configurationDirectory) writeGeneratedFiles(dstPath string) error { + fmt.Println("Count of generated files:", len(c.generatedFiles)) + for filename, config := range c.generatedFiles { + outFilename := filepath.Join(dstPath, filename) + fmt.Println("Writing generated file:", outFilename) + + err := os.WriteFile(outFilename, []byte(config), 0700) + if err != nil { + return err + } + fmt.Println("Wrote generated file:", outFilename) + } + + return nil +} diff --git a/internal/teststep/directory_test.go b/internal/teststep/directory_test.go index 0118b91d5..f64419f27 100644 --- a/internal/teststep/directory_test.go +++ b/internal/teststep/directory_test.go @@ -432,17 +432,17 @@ func TestConfigurationDirectory_Write(t *testing.T) { }, "no-config": { configDirectory: configurationDirectory{ - "testdata/empty_dir", + directory: "testdata/empty_dir", }, }, "dir-single-file": { configDirectory: configurationDirectory{ - "testdata/random", + directory: "testdata/random", }, }, "dir-multiple-files": { configDirectory: configurationDirectory{ - "testdata/random_multiple_files", + directory: "testdata/random_multiple_files", }, }, } @@ -523,17 +523,17 @@ func TestConfigurationDirectory_Write_AbsolutePath(t *testing.T) { }, "no-config": { configDirectory: configurationDirectory{ - "testdata/empty_dir", + directory: "testdata/empty_dir", }, }, "dir-single-file": { configDirectory: configurationDirectory{ - "testdata/random", + directory: "testdata/random", }, }, "dir-multiple-files": { configDirectory: configurationDirectory{ - "testdata/random_multiple_files", + directory: "testdata/random_multiple_files", }, }, } @@ -607,6 +607,81 @@ func TestConfigurationDirectory_Write_AbsolutePath(t *testing.T) { } } +func TestConfigurationDirectory_Write_WithGeneratedFiles(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + configDirectory configurationDirectory + expectedError *regexp.Regexp + }{ + "dir-single-file": { + configDirectory: configurationDirectory{ + directory: "testdata/random", + generatedFiles: map[string]string{ + "import.tf": `terraform {\nimport\n{\nto = satellite.the_moon\nid = "moon"\n}\n}\n`, + }, + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + + err := testCase.configDirectory.Write(context.Background(), tempDir) + if err != nil { + t.Errorf("unexpected error %s", err) + } + + dirEntries, err := os.ReadDir(testCase.configDirectory.directory) + if err != nil { + t.Errorf("error reading directory: %s", err) + } + + tempDirEntries, err := os.ReadDir(tempDir) + + if err != nil { + t.Errorf("error reading temp directory: %s", err) + } + + if len(tempDirEntries)-len(dirEntries) != 1 { + t.Errorf("expected %d dir entries, got %d dir entries", len(dirEntries)+1, tempDirEntries) + } + + for _, entry := range dirEntries { + filename := entry.Name() + expectedContent, err := os.ReadFile(filepath.Join(testCase.configDirectory.directory, filename)) + if err != nil { + t.Errorf("error reading file from config directory %s: %s", filename, err) + } + + content, err := os.ReadFile(filepath.Join(tempDir, filename)) + if err != nil { + t.Errorf("error reading generated file %s: %s", filename, err) + } + + if diff := cmp.Diff(expectedContent, content); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + } + + generatedFiles := testCase.configDirectory.generatedFiles + for filename, expectedContent := range generatedFiles { + content, err := os.ReadFile(filepath.Join(tempDir, filename)) + if err != nil { + t.Errorf("error reading generated file %s: %s", filename, err) + } + + if diff := cmp.Diff([]byte(expectedContent), content); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + } + }) + } +} + var fileInfoComparer = cmp.Comparer(func(x, y os.FileInfo) bool { if x.Name() != y.Name() { return false diff --git a/internal/teststep/file.go b/internal/teststep/file.go index 6de3f0752..aba85ce54 100644 --- a/internal/teststep/file.go +++ b/internal/teststep/file.go @@ -12,7 +12,8 @@ import ( var _ Config = configurationFile{} type configurationFile struct { - file string + file string + appendedConfig string } // HasConfigurationFiles is used during validation to ensure that @@ -85,10 +86,23 @@ func (c configurationFile) Write(ctx context.Context, dest string) error { } err := copyFile(configFile, dest) - if err != nil { return err } + if len(c.appendedConfig) > 0 { + err := appendToFile(configFile, c.appendedConfig) + if err != nil { + return err + } + } + return nil } + +func (c configurationFile) Append(_ context.Context, config string) Config { + return configurationFile{ + file: c.file, + appendedConfig: config, + } +} diff --git a/internal/teststep/string.go b/internal/teststep/string.go index 4143b484d..0d004250c 100644 --- a/internal/teststep/string.go +++ b/internal/teststep/string.go @@ -9,6 +9,7 @@ import ( "fmt" "os" "path/filepath" + "strings" ) var _ Config = configurationString{} @@ -59,3 +60,9 @@ func (c configurationString) Write(ctx context.Context, dest string) error { return nil } + +func (c configurationString) Append(_ context.Context, config string) Config { + return configurationString{ + raw: strings.Join([]string{c.raw, config}, "\n"), + } +}