From 64fb36dc54317737c0664f17a0804dfd96f56fb1 Mon Sep 17 00:00:00 2001 From: Oleksandr Levchenkov Date: Thu, 6 Jun 2024 13:20:41 +0300 Subject: [PATCH] add override implementation for testing framework (#1499) Signed-off-by: ollevche Signed-off-by: Oleksandr Levchenkov Co-authored-by: Janos <86970079+janosdebugs@users.noreply.github.com> Co-authored-by: Ronny Orot --- CHANGELOG.md | 9 +- internal/addrs/module.go | 19 + internal/addrs/module_test.go | 77 +++ internal/addrs/parse_target.go | 5 +- internal/addrs/resource.go | 26 + internal/addrs/resource_test.go | 135 +++++ internal/command/e2etest/test_test.go | 29 ++ .../testdata/overrides-in-tests/first/main.tf | 13 + .../testdata/overrides-in-tests/main.tf | 59 +++ .../overrides-in-tests/main.tftest.hcl | 223 ++++++++ .../testdata/overrides-in-tests/rand/main.tf | 8 + .../overrides-in-tests/second/main.tf | 8 + .../overrides-in-tests/second/third/main.tf | 4 + internal/command/test.go | 6 + internal/command/validate.go | 3 + internal/configs/config.go | 217 +++++++- .../configs/hcl2shim/mock_value_composer.go | 353 +++++++++++++ .../hcl2shim/mock_value_composer_test.go | 492 ++++++++++++++++++ internal/configs/module.go | 4 + internal/configs/named_values.go | 8 + internal/configs/resource.go | 10 + internal/configs/test_file.go | 312 ++++++++++- internal/tofu/graph_builder_apply.go | 4 +- internal/tofu/graph_builder_eval.go | 4 +- internal/tofu/graph_builder_plan.go | 4 +- internal/tofu/node_module_expand.go | 21 +- internal/tofu/node_module_expand_test.go | 6 +- internal/tofu/node_output.go | 24 +- .../tofu/node_resource_abstract_instance.go | 49 +- internal/tofu/provider_for_test_framework.go | 131 +++++ internal/tofu/transform_config.go | 6 + internal/tofu/transform_root.go | 9 +- .../override_module/bucket_meta/main.tf | 13 + .../test/examples/override_module/main.tf | 12 + .../examples/override_module/main.tftest.hcl | 32 ++ .../test/examples/override_resource/main.tf | 11 + .../override_resource/main.tftest.hcl | 25 + website/docs/cli/commands/test/index.mdx | 102 ++++ 38 files changed, 2437 insertions(+), 36 deletions(-) create mode 100644 internal/command/e2etest/testdata/overrides-in-tests/first/main.tf create mode 100644 internal/command/e2etest/testdata/overrides-in-tests/main.tf create mode 100644 internal/command/e2etest/testdata/overrides-in-tests/main.tftest.hcl create mode 100644 internal/command/e2etest/testdata/overrides-in-tests/rand/main.tf create mode 100644 internal/command/e2etest/testdata/overrides-in-tests/second/main.tf create mode 100644 internal/command/e2etest/testdata/overrides-in-tests/second/third/main.tf create mode 100644 internal/configs/hcl2shim/mock_value_composer.go create mode 100644 internal/configs/hcl2shim/mock_value_composer_test.go create mode 100644 internal/tofu/provider_for_test_framework.go create mode 100644 website/docs/cli/commands/test/examples/override_module/bucket_meta/main.tf create mode 100644 website/docs/cli/commands/test/examples/override_module/main.tf create mode 100644 website/docs/cli/commands/test/examples/override_module/main.tftest.hcl create mode 100644 website/docs/cli/commands/test/examples/override_resource/main.tf create mode 100644 website/docs/cli/commands/test/examples/override_resource/main.tftest.hcl diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d95a5a13f..3869f97c23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,20 +3,21 @@ UPGRADE NOTES: NEW FEATURES: +* Added support for `override_resource`, `override_data` and `override_module` blocks in testing framework. ([1499](https://github.com/opentofu/opentofu/pull/1499)) ENHANCEMENTS: -* Added `tofu test -json` types to website Machine-Readable UI documentation ([1408](https://github.com/opentofu/opentofu/issues/1408)) +* Added `tofu test -json` types to website Machine-Readable UI documentation. ([1408](https://github.com/opentofu/opentofu/issues/1408)) * Made `tofu plan` with `generate-config-out` flag replace JSON strings with `jsonencode` functions calls. ([#1595](https://github.com/opentofu/opentofu/pull/1595)) * Make state persistence interval configurable via `TF_STATE_PERSIST_INTERVAL` environment variable ([#1591](https://github.com/opentofu/opentofu/pull/1591)) * Improved performance of writing state files and reduced their size using compact json encoding. ([#1647](https://github.com/opentofu/opentofu/pull/1647)) * Allow to reference variable inside the `variables` block of a test file. ([1488](https://github.com/opentofu/opentofu/pull/1488)) BUG FIXES: -* Fixed crash in gcs backend when using certain commands ([#1618](https://github.com/opentofu/opentofu/pull/1618)) -* Fix inmem backend crash due to missing struct field ([#1619](https://github.com/opentofu/opentofu/pull/1619)) +* Fixed crash in gcs backend when using certain commands. ([#1618](https://github.com/opentofu/opentofu/pull/1618)) +* Fixed inmem backend crash due to missing struct field. ([#1619](https://github.com/opentofu/opentofu/pull/1619)) * Added a check in the `tofu test` to validate that the names of test run blocks do not contain spaces. ([#1489](https://github.com/opentofu/opentofu/pull/1489)) * `tofu test` now supports accessing module outputs when the module has no resources. ([#1409](https://github.com/opentofu/opentofu/pull/1409)) -* Fixed support for provider functions in tests ([#1603](https://github.com/opentofu/opentofu/pull/1603)) +* Fixed support for provider functions in tests. ([#1603](https://github.com/opentofu/opentofu/pull/1603)) * Added a better error message on `for_each` block with sensitive value of unsuitable type. ([#1485](https://github.com/opentofu/opentofu/pull/1485)) * Fix race condition on locking in gcs backend ([#1342](https://github.com/opentofu/opentofu/pull/1342)) * Fix bug where provider functions were unusable in variables and outputs ([#1689](https://github.com/opentofu/opentofu/pull/1689)) diff --git a/internal/addrs/module.go b/internal/addrs/module.go index 81becd077a..ca051f21c2 100644 --- a/internal/addrs/module.go +++ b/internal/addrs/module.go @@ -177,6 +177,25 @@ func (m Module) configRemovableSigil() { // Empty function so Module will fulfill the requirements of the removable interface } +// ParseModule parses a module address from the given traversal, +// which has to contain only the module address with no resource/data/variable/etc. +// This function only supports module addresses without instance keys (as the +// returned Module struct doesn't support instance keys) and will return an +// error if it encounters one. +func ParseModule(traversal hcl.Traversal) (Module, tfdiags.Diagnostics) { + mod, remain, diags := parseModulePrefix(traversal) + if !diags.HasErrors() && len(remain) != 0 { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Module address expected", + Detail: "It's not allowed to reference anything other than module here.", + Subject: remain[0].SourceRange().Ptr(), + }) + } + + return mod, diags +} + // parseModulePrefix parses a module address from the given traversal, // returning the module address and the remaining traversal. // For example, if the input traversal is ["module","a","module","b", diff --git a/internal/addrs/module_test.go b/internal/addrs/module_test.go index 410720dbed..9ef6b6576d 100644 --- a/internal/addrs/module_test.go +++ b/internal/addrs/module_test.go @@ -8,6 +8,10 @@ package addrs import ( "fmt" "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" ) func TestModuleEqual_true(t *testing.T) { @@ -99,3 +103,76 @@ func BenchmarkModuleStringLong(b *testing.B) { module.String() } } + +func TestParseModule(t *testing.T) { + t.Parallel() + + tests := []struct { + Input string + WantModule Module + WantErr string + }{ + { + Input: "module.a", + WantModule: []string{"a"}, + }, + { + Input: "module.a.module.b", + WantModule: []string{"a", "b"}, + }, + { + Input: "module.a.module.b.c.d", + WantErr: "Module address expected: It's not allowed to reference anything other than module here.", + }, + { + Input: "a.b.c.d", + WantErr: "Module address expected: It's not allowed to reference anything other than module here.", + }, + { + Input: "module", + WantErr: `Invalid address operator: Prefix "module." must be followed by a module name.`, + }, + { + Input: "module.a[0]", + WantErr: `Module instance address with keys is not allowed: Module address cannot be a module instance (e.g. "module.a[0]"), it must be a module instead (e.g. "module.a").`, + }, + { + Input: `module.a["k"]`, + WantErr: `Module instance address with keys is not allowed: Module address cannot be a module instance (e.g. "module.a[0]"), it must be a module instead (e.g. "module.a").`, + }, + } + + for _, test := range tests { + test := test + + t.Run(test.Input, func(t *testing.T) { + t.Parallel() + + traversal, hclDiags := hclsyntax.ParseTraversalAbs([]byte(test.Input), "", hcl.InitialPos) + if hclDiags.HasErrors() { + t.Fatalf("Bug in tests: %s", hclDiags.Error()) + } + + mod, diags := ParseModule(traversal) + + switch { + case test.WantErr != "": + if !diags.HasErrors() { + t.Fatalf("Unexpected success, wanted error: %s", test.WantErr) + } + + gotErr := diags.Err().Error() + if gotErr != test.WantErr { + t.Fatalf("Mismatched error\nGot: %s\nWant: %s", gotErr, test.WantErr) + } + default: + if diags.HasErrors() { + t.Fatalf("Unexpected error: %s", diags.Err().Error()) + } + if diff := cmp.Diff(test.WantModule, mod); diff != "" { + t.Fatalf("Mismatched result:\n%s", diff) + } + } + }) + } +} diff --git a/internal/addrs/parse_target.go b/internal/addrs/parse_target.go index 98997689fc..3a4e6e307c 100644 --- a/internal/addrs/parse_target.go +++ b/internal/addrs/parse_target.go @@ -68,9 +68,8 @@ func ParseTarget(traversal hcl.Traversal) (*Target, tfdiags.Diagnostics) { } func parseResourceInstanceUnderModule(moduleAddr ModuleInstance, remain hcl.Traversal) (AbsResourceInstance, tfdiags.Diagnostics) { - // Note that this helper is used as part of both ParseTarget and - // ParseMoveEndpoint, so its error messages should be generic - // enough to suit both situations. + // Note that this helper is used as part of multiple public functions + // so its error messages should be generic enough to suit all the situations. var diags tfdiags.Diagnostics diff --git a/internal/addrs/resource.go b/internal/addrs/resource.go index fd11007062..e3d0f6b625 100644 --- a/internal/addrs/resource.go +++ b/internal/addrs/resource.go @@ -8,6 +8,9 @@ package addrs import ( "fmt" "strings" + + "github.com/hashicorp/hcl/v2" + "github.com/opentofu/opentofu/internal/tfdiags" ) // Resource is an address for a resource block within configuration, which @@ -391,6 +394,29 @@ type ConfigResource struct { Resource Resource } +// ParseConfigResource parses the module address from the given traversal +// and then parses the resource address from the leftover. Returning ConfigResource +// contains both module and resource addresses. ParseConfigResource doesn't support +// instance keys and will return an error if it encounters one. +func ParseConfigResource(traversal hcl.Traversal) (ConfigResource, tfdiags.Diagnostics) { + modulePath, remainTraversal, diags := parseModulePrefix(traversal) + if diags.HasErrors() { + return ConfigResource{}, diags + } + + if len(remainTraversal) == 0 { + return ConfigResource{}, diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Module address is not allowed", + Detail: "Expected reference to either resource or data block. Provided reference appears to be a module.", + Subject: traversal.SourceRange().Ptr(), + }) + } + + configRes, moreDiags := parseResourceUnderModule(modulePath, remainTraversal) + return configRes, diags.Append(moreDiags) +} + // Resource returns the address of a particular resource within the module. func (m Module) Resource(mode ResourceMode, typeName string, name string) ConfigResource { return ConfigResource{ diff --git a/internal/addrs/resource_test.go b/internal/addrs/resource_test.go index 21498cadb1..9ecfb7bfd4 100644 --- a/internal/addrs/resource_test.go +++ b/internal/addrs/resource_test.go @@ -8,6 +8,10 @@ package addrs import ( "fmt" "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" ) func TestResourceEqual_true(t *testing.T) { @@ -350,3 +354,134 @@ func TestConfigResourceEqual_false(t *testing.T) { }) } } + +func TestParseConfigResource(t *testing.T) { + t.Parallel() + + tests := []struct { + Input string + WantConfigResource ConfigResource + WantErr string + }{ + { + Input: "a.b", + WantConfigResource: ConfigResource{ + Module: RootModule, + Resource: Resource{ + Mode: ManagedResourceMode, + Type: "a", + Name: "b", + }, + }, + }, + { + Input: "data.a.b", + WantConfigResource: ConfigResource{ + Module: RootModule, + Resource: Resource{ + Mode: DataResourceMode, + Type: "a", + Name: "b", + }, + }, + }, + { + Input: "module.a.b.c", + WantConfigResource: ConfigResource{ + Module: []string{"a"}, + Resource: Resource{ + Mode: ManagedResourceMode, + Type: "b", + Name: "c", + }, + }, + }, + { + Input: "module.a.data.b.c", + WantConfigResource: ConfigResource{ + Module: []string{"a"}, + Resource: Resource{ + Mode: DataResourceMode, + Type: "b", + Name: "c", + }, + }, + }, + { + Input: "module.a.module.b.c.d", + WantConfigResource: ConfigResource{ + Module: []string{"a", "b"}, + Resource: Resource{ + Mode: ManagedResourceMode, + Type: "c", + Name: "d", + }, + }, + }, + { + Input: "module.a.module.b.data.c.d", + WantConfigResource: ConfigResource{ + Module: []string{"a", "b"}, + Resource: Resource{ + Mode: DataResourceMode, + Type: "c", + Name: "d", + }, + }, + }, + { + Input: "module.a.module.b", + WantErr: "Module address is not allowed: Expected reference to either resource or data block. Provided reference appears to be a module.", + }, + { + Input: "module", + WantErr: `Invalid address operator: Prefix "module." must be followed by a module name.`, + }, + { + Input: "module.a.module.b.c", + WantErr: "Invalid address: Resource specification must include a resource type and name.", + }, + { + Input: "module.a.module.b.c.d[0]", + WantErr: `Resource instance address with keys is not allowed: Resource address cannot be a resource instance (e.g. "null_resource.a[0]"), it must be a resource instead (e.g. "null_resource.a").`, + }, + { + Input: "module.a.module.b.data.c.d[0]", + WantErr: `Resource instance address with keys is not allowed: Resource address cannot be a resource instance (e.g. "null_resource.a[0]"), it must be a resource instead (e.g. "null_resource.a").`, + }, + } + + for _, test := range tests { + test := test + + t.Run(test.Input, func(t *testing.T) { + t.Parallel() + + traversal, hclDiags := hclsyntax.ParseTraversalAbs([]byte(test.Input), "", hcl.InitialPos) + if hclDiags.HasErrors() { + t.Fatalf("Bug in tests: %s", hclDiags.Error()) + } + + configRes, diags := ParseConfigResource(traversal) + + switch { + case test.WantErr != "": + if !diags.HasErrors() { + t.Fatalf("Unexpected success, wanted error: %s", test.WantErr) + } + + gotErr := diags.Err().Error() + if gotErr != test.WantErr { + t.Fatalf("Mismatched error\nGot: %s\nWant: %s", gotErr, test.WantErr) + } + default: + if diags.HasErrors() { + t.Fatalf("Unexpected error: %s", diags.Err().Error()) + } + if diff := cmp.Diff(test.WantConfigResource, configRes); diff != "" { + t.Fatalf("Mismatched result:\n%s", diff) + } + } + }) + } +} diff --git a/internal/command/e2etest/test_test.go b/internal/command/e2etest/test_test.go index 2bd196c48e..ae81d4f474 100644 --- a/internal/command/e2etest/test_test.go +++ b/internal/command/e2etest/test_test.go @@ -51,3 +51,32 @@ func TestMultipleRunBlocks(t *testing.T) { } } } + +func TestOverrides(t *testing.T) { + // This test fetches "local" and "random" providers. + skipIfCannotAccessNetwork(t) + + tf := e2e.NewBinary(t, tofuBin, filepath.Join("testdata", "overrides-in-tests")) + + stdout, stderr, err := tf.Run("init") + if err != nil { + t.Errorf("unexpected error on 'init': %v", err) + } + if stderr != "" { + t.Errorf("unexpected stderr output on 'init':\n%s", stderr) + } + if stdout == "" { + t.Errorf("expected some output on 'init', got nothing") + } + + stdout, stderr, err = tf.Run("test") + if err != nil { + t.Errorf("unexpected error on 'test': %v", err) + } + if stderr != "" { + t.Errorf("unexpected stderr output on 'test':\n%s", stderr) + } + if !strings.Contains(stdout, "11 passed, 0 failed") { + t.Errorf("output doesn't have expected success string:\n%s", stdout) + } +} diff --git a/internal/command/e2etest/testdata/overrides-in-tests/first/main.tf b/internal/command/e2etest/testdata/overrides-in-tests/first/main.tf new file mode 100644 index 0000000000..69e57bd240 --- /dev/null +++ b/internal/command/e2etest/testdata/overrides-in-tests/first/main.tf @@ -0,0 +1,13 @@ +resource "local_file" "dont_create_me" { + filename = "${path.module}/dont_create_me.txt" + content = "101" +} + +resource "local_file" "create_me" { + filename = "${path.module}/create_me.txt" + content = "101" +} + +output "create_me_filename" { + value = "main.tf" +} diff --git a/internal/command/e2etest/testdata/overrides-in-tests/main.tf b/internal/command/e2etest/testdata/overrides-in-tests/main.tf new file mode 100644 index 0000000000..f8cf1847e1 --- /dev/null +++ b/internal/command/e2etest/testdata/overrides-in-tests/main.tf @@ -0,0 +1,59 @@ +module "first" { + source = "./first" +} + +module "second" { + source = "./second" +} + +resource "local_file" "dont_create_me" { + filename = "${path.module}/dont_create_me.txt" + content = "101" +} + +resource "local_file" "create_me" { + filename = "${path.module}/create_me.txt" + content = "101" +} + +data "local_file" "second_mod_file" { + filename = module.first.create_me_filename +} + +resource "random_integer" "count" { + count = 2 + + min = 1 + max = 10 +} + +resource "random_integer" "for_each" { + for_each = { + "a": { + "min": 1 + "max": 10 + } + "b": { + "min": 20 + "max": 30 + } + } + + min = each.value.min + max = each.value.max +} + +module "rand_for_each" { + for_each = { + "a": 1 + "b": 2 + } + + source = "./rand" +} + +module "rand_count" { + count = 2 + + source = "./rand" +} diff --git a/internal/command/e2etest/testdata/overrides-in-tests/main.tftest.hcl b/internal/command/e2etest/testdata/overrides-in-tests/main.tftest.hcl new file mode 100644 index 0000000000..4d761f460f --- /dev/null +++ b/internal/command/e2etest/testdata/overrides-in-tests/main.tftest.hcl @@ -0,0 +1,223 @@ +override_module { + target = module.second +} + +override_resource { + target = local_file.dont_create_me +} + +override_resource { + target = module.first.local_file.dont_create_me +} + +run "check_root_overridden_res" { + assert { + condition = !fileexists("${path.module}/dont_create_me.txt") + error_message = "File 'dont_create_me.txt' must not be created in the root module" + } +} + +run "check_root_overridden_res_twice" { + override_resource { + target = local_file.dont_create_me + values = { + file_permission = "0333" + } + } + + assert { + condition = !fileexists("${path.module}/dont_create_me.txt") && local_file.dont_create_me.file_permission == "0333" + error_message = "File 'dont_create_me.txt' must not be created in the root module and its file_permission must be overridden" + } +} + +run "check_root_data" { + assert { + condition = data.local_file.second_mod_file.content == file("main.tf") + error_message = "Content from the local_file data in the root module must be from real file" + } +} + +run "check_root_overridden_data" { + override_data { + target = data.local_file.second_mod_file + values = { + content = "101" + } + } + + assert { + condition = data.local_file.second_mod_file.content == "101" + error_message = "Content from the local_file data in the root module must be overridden" + } +} + +run "check_overridden_module_output" { + override_module { + target = module.first + outputs = { + create_me_filename = "main.tftest.hcl" + } + } + + assert { + condition = data.local_file.second_mod_file.content == file("main.tftest.hcl") + error_message = "Overridden module output is not used in the depending data resource" + } +} + +run "check_first_module" { + assert { + condition = fileexists("${path.module}/first/create_me.txt") + error_message = "File 'create_me.txt' must be created in the first module" + } +} + +run "check_first_module_overridden_res" { + assert { + condition = !fileexists("${path.module}/first/dont_create_me.txt") + error_message = "File 'dont_create_me.txt' must not be created in the first module" + } +} + +run "check_second_module" { + assert { + condition = !fileexists("${path.module}/second/dont_create_me.txt") + error_message = "File 'dont_create_me.txt' must not be created in the second module" + } +} + +run "check_third_module" { + assert { + condition = !fileexists("${path.module}/second/third/dont_create_me.txt") + error_message = "File 'dont_create_me.txt' must not be created in the third module" + } +} + +override_resource { + target = random_integer.count +} + +override_resource { + target = random_integer.for_each +} + +override_module { + target = module.rand_count +} + +override_module { + target = module.rand_for_each +} + +run "check_for_each_n_count_mocked" { + assert { + condition = random_integer.count[0].result == 0 + error_message = "Mocked random integer should be 0" + } + + assert { + condition = random_integer.count[1].result == 0 + error_message = "Mocked random integer should be 0" + } + + assert { + condition = random_integer.for_each["a"].result == 0 + error_message = "Mocked random integer should be 0" + } + + assert { + condition = random_integer.for_each["b"].result == 0 + error_message = "Mocked random integer should be 0" + } + + assert { + condition = module.rand_count[0].random_integer == null + error_message = "Mocked random integer should be null" + } + + assert { + condition = module.rand_count[1].random_integer == null + error_message = "Mocked random integer should be null" + } + + assert { + condition = module.rand_for_each["a"].random_integer == null + error_message = "Mocked random integer should be null" + } + + assert { + condition = module.rand_for_each["b"].random_integer == null + error_message = "Mocked random integer should be null" + } +} + +run "check_for_each_n_count_overridden" { + override_resource { + target = random_integer.count + values = { + result = 101 + } + } + + assert { + condition = random_integer.count[0].result == 101 + error_message = "Overridden random integer should be 101" + } + + assert { + condition = random_integer.count[1].result == 101 + error_message = "Overridden random integer should be 101" + } + + override_resource { + target = random_integer.for_each + values = { + result = 101 + } + } + + assert { + condition = random_integer.for_each["a"].result == 101 + error_message = "Overridden random integer should be 101" + } + + assert { + condition = random_integer.for_each["b"].result == 101 + error_message = "Overridden random integer should be 101" + } + + override_module { + target = module.rand_count + outputs = { + random_integer = 101 + } + } + + assert { + condition = module.rand_count[0].random_integer == 101 + error_message = "Mocked random integer should be 101" + } + + assert { + condition = module.rand_count[1].random_integer == 101 + error_message = "Mocked random integer should be 101" + } + + override_module { + target = module.rand_for_each + outputs = { + random_integer = 101 + } + } + + assert { + condition = module.rand_for_each["a"].random_integer == 101 + error_message = "Mocked random integer should be 101" + } + + assert { + condition = module.rand_for_each["b"].random_integer == 101 + error_message = "Mocked random integer should be 101" + } +} diff --git a/internal/command/e2etest/testdata/overrides-in-tests/rand/main.tf b/internal/command/e2etest/testdata/overrides-in-tests/rand/main.tf new file mode 100644 index 0000000000..eb81364918 --- /dev/null +++ b/internal/command/e2etest/testdata/overrides-in-tests/rand/main.tf @@ -0,0 +1,8 @@ +resource "random_integer" "main" { + min = 1 + max = 20 +} + +output "random_integer" { + value = random_integer.main.id +} diff --git a/internal/command/e2etest/testdata/overrides-in-tests/second/main.tf b/internal/command/e2etest/testdata/overrides-in-tests/second/main.tf new file mode 100644 index 0000000000..82c3f2c118 --- /dev/null +++ b/internal/command/e2etest/testdata/overrides-in-tests/second/main.tf @@ -0,0 +1,8 @@ +module "third" { + source = "./third" +} + +resource "local_file" "dont_create_me" { + filename = "${path.module}/dont_create_me.txt" + content = "101" +} diff --git a/internal/command/e2etest/testdata/overrides-in-tests/second/third/main.tf b/internal/command/e2etest/testdata/overrides-in-tests/second/third/main.tf new file mode 100644 index 0000000000..2d0eee840d --- /dev/null +++ b/internal/command/e2etest/testdata/overrides-in-tests/second/third/main.tf @@ -0,0 +1,4 @@ +resource "local_file" "dont_create_me" { + filename = "${path.module}/dont_create_me.txt" + content = "101" +} diff --git a/internal/command/test.go b/internal/command/test.go index a3d695f5cd..5ce3825316 100644 --- a/internal/command/test.go +++ b/internal/command/test.go @@ -479,6 +479,12 @@ func (runner *TestFileRunner) ExecuteTestRun(run *moduletest.Run, file *modulete return state, false } + run.Diagnostics = run.Diagnostics.Append(file.Config.Validate()) + if run.Diagnostics.HasErrors() { + run.Status = moduletest.Error + return state, false + } + run.Diagnostics = run.Diagnostics.Append(run.Config.Validate()) if run.Diagnostics.HasErrors() { run.Status = moduletest.Error diff --git a/internal/command/validate.go b/internal/command/validate.go index 7d854f7601..887202af32 100644 --- a/internal/command/validate.go +++ b/internal/command/validate.go @@ -110,6 +110,9 @@ func (c *ValidateCommand) validate(dir, testDir string, noTests bool) tfdiags.Di // We'll also do a quick validation of the OpenTofu test files. These live // outside the OpenTofu graph so we have to do this separately. for _, file := range cfg.Module.Tests { + + diags = diags.Append(file.Validate()) + for _, run := range file.Runs { if run.Module != nil { diff --git a/internal/configs/config.go b/internal/configs/config.go index 91b0b01770..291b4f355c 100644 --- a/internal/configs/config.go +++ b/internal/configs/config.go @@ -880,7 +880,37 @@ func (c *Config) CheckCoreVersionRequirements() hcl.Diagnostics { func (c *Config) TransformForTest(run *TestRun, file *TestFile) (func(), hcl.Diagnostics) { var diags hcl.Diagnostics - // Currently, we only need to override the provider settings. + // These transformation functions must be in sync of what is being transformed, + // currently all the functions operate on different fields of configuration. + transformFuncs := []func(*TestRun, *TestFile) (func(), hcl.Diagnostics){ + c.transformProviderConfigsForTest, + c.transformOverriddenResourcesForTest, + c.transformOverriddenModulesForTest, + } + + var resetFuncs []func() + + // We call each function to transform the configuration + // and gather transformation diags as well as reset functions. + for _, f := range transformFuncs { + resetFunc, moreDiags := f(run, file) + diags = append(diags, moreDiags...) + resetFuncs = append(resetFuncs, resetFunc) + } + + // Order of calls doesn't matter as long as transformation functions + // don't operate on the same set of fields. + return func() { + for _, f := range resetFuncs { + f() + } + }, diags +} + +func (c *Config) transformProviderConfigsForTest(run *TestRun, file *TestFile) (func(), hcl.Diagnostics) { + var diags hcl.Diagnostics + + // We need to override the provider settings. // // We can have a set of providers defined within the config, we can also // have a set of providers defined within the test file. Then the run can @@ -955,8 +985,193 @@ func (c *Config) TransformForTest(run *TestRun, file *TestFile) (func(), hcl.Dia } c.Module.ProviderConfigs = next + return func() { // Reset the original config within the returned function. c.Module.ProviderConfigs = previous }, diags } + +func (c *Config) transformOverriddenResourcesForTest(run *TestRun, file *TestFile) (func(), hcl.Diagnostics) { + resources, diags := mergeOverriddenResources(run.OverrideResources, file.OverrideResources) + + // We want to pass override values to resources being overridden. + for _, overrideRes := range resources { + targetConfig := c.Root.Descendent(overrideRes.TargetParsed.Module) + if targetConfig == nil { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: fmt.Sprintf("Module not found: %v", overrideRes.TargetParsed.Module), + Detail: "Target points to resource in undefined module. Please, ensure module exists.", + Subject: overrideRes.Target.SourceRange().Ptr(), + }) + continue + } + + res := targetConfig.Module.ResourceByAddr(overrideRes.TargetParsed.Resource) + if res == nil { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: fmt.Sprintf("Resource not found: %v", overrideRes.TargetParsed), + Detail: "Target points to undefined resource. Please, ensure resource exists.", + Subject: overrideRes.Target.SourceRange().Ptr(), + }) + continue + } + + if res.Mode != overrideRes.Mode { + blockName, targetMode := blockNameOverrideResource, "data" + if overrideRes.Mode == addrs.DataResourceMode { + blockName, targetMode = blockNameOverrideData, "resource" + } + // It could be a warning, but for the sake of consistent UX let's make it an error + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: fmt.Sprintf("Unsupported `%v` target in `%v` block", targetMode, blockName), + Detail: fmt.Sprintf("Target `%v` is `%v` block itself and cannot be overridden with `%v`.", + overrideRes.TargetParsed, targetMode, blockName), + Subject: overrideRes.Target.SourceRange().Ptr(), + }) + } + + res.IsOverridden = true + res.OverrideValues = overrideRes.Values + } + + return func() { + // Reset all the overridden resources. + for _, o := range run.OverrideResources { + m := c.Root.Descendent(o.TargetParsed.Module) + if m == nil { + continue + } + + res := m.Module.ResourceByAddr(o.TargetParsed.Resource) + if res == nil { + continue + } + + res.IsOverridden = false + res.OverrideValues = nil + } + }, diags +} + +func (c *Config) transformOverriddenModulesForTest(run *TestRun, file *TestFile) (func(), hcl.Diagnostics) { + modules, diags := mergeOverriddenModules(run.OverrideModules, file.OverrideModules) + + for _, overrideMod := range modules { + targetConfig := c.Root.Descendent(overrideMod.TargetParsed) + if targetConfig == nil { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: fmt.Sprintf("Module not found: %v", overrideMod.TargetParsed), + Detail: "Target points to an undefined module. Please, ensure module exists.", + Subject: overrideMod.Target.SourceRange().Ptr(), + }) + continue + } + + for overrideKey := range overrideMod.Outputs { + if _, ok := targetConfig.Module.Outputs[overrideKey]; !ok { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagWarning, + Summary: fmt.Sprintf("Output not found: %v", overrideKey), + Detail: "Specified output to override is not present in the module and will be ignored.", + Subject: overrideMod.Target.SourceRange().Ptr(), + }) + } + } + + targetConfig.Module.IsOverridden = true + + for key, output := range targetConfig.Module.Outputs { + output.IsOverridden = true + + // Override outputs are optional so it's okay to set IsOverridden with no OverrideValue. + if v, ok := overrideMod.Outputs[key]; ok { + output.OverrideValue = &v + } + } + } + + return func() { + for _, overrideMod := range run.OverrideModules { + targetConfig := c.Root.Descendent(overrideMod.TargetParsed) + if targetConfig == nil { + continue + } + + targetConfig.Module.IsOverridden = false + + for _, output := range targetConfig.Module.Outputs { + output.IsOverridden = false + output.OverrideValue = nil + } + } + }, diags +} + +func mergeOverriddenResources(runResources, fileResources []*OverrideResource) ([]*OverrideResource, hcl.Diagnostics) { + // resAddrsInRun is a unique set of resource addresses in run block. + // It's already validated for duplicates previously. + resAddrsInRun := make(map[string]struct{}) + for _, r := range runResources { + resAddrsInRun[r.TargetParsed.String()] = struct{}{} + } + + var diags hcl.Diagnostics + + resources := runResources + for _, r := range fileResources { + addr := r.TargetParsed.String() + + // Run and file override resources could have overlap + // so we warn user and proceed with the definition from the smaller scope. + if _, ok := resAddrsInRun[addr]; ok { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagWarning, + Summary: fmt.Sprintf("Multiple `%v` blocks for the same address", r.getBlockName()), + Detail: fmt.Sprintf("`%v` is overridden in both global file and local run blocks. The declaration in global file block will be ignored.", addr), + Subject: r.Target.SourceRange().Ptr(), + }) + continue + } + + resources = append(resources, r) + } + + return resources, diags +} + +func mergeOverriddenModules(runModules, fileModules []*OverrideModule) ([]*OverrideModule, hcl.Diagnostics) { + // modAddrsInRun is a unique set of module addresses in run block. + // It's already validated for duplicates previously. + modAddrsInRun := make(map[string]struct{}) + for _, m := range runModules { + modAddrsInRun[m.TargetParsed.String()] = struct{}{} + } + + var diags hcl.Diagnostics + + modules := runModules + for _, m := range fileModules { + addr := m.TargetParsed.String() + + // Run and file override modules could have overlap + // so we warn user and proceed with the definition from the smaller scope. + if _, ok := modAddrsInRun[addr]; ok { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagWarning, + Summary: "Multiple `override_module` blocks for the same address", + Detail: fmt.Sprintf("Module `%v` is overridden in both global file and local run blocks. The declaration in global file block will be ignored.", addr), + Subject: m.Target.SourceRange().Ptr(), + }) + continue + } + + modules = append(modules, m) + } + + return modules, diags +} diff --git a/internal/configs/hcl2shim/mock_value_composer.go b/internal/configs/hcl2shim/mock_value_composer.go new file mode 100644 index 0000000000..743cac696b --- /dev/null +++ b/internal/configs/hcl2shim/mock_value_composer.go @@ -0,0 +1,353 @@ +package hcl2shim + +import ( + "fmt" + "math/rand" + "strings" + + "github.com/opentofu/opentofu/internal/configs/configschema" + "github.com/opentofu/opentofu/internal/tfdiags" + "github.com/zclconf/go-cty/cty" +) + +// ComposeMockValueBySchema composes mock value based on schema configuration. It uses +// configuration value as a baseline and populates null values with provided defaults. +// If the provided defaults doesn't contain needed fields, ComposeMockValueBySchema uses +// its own defaults. ComposeMockValueBySchema fails if schema contains dynamic types. +func ComposeMockValueBySchema(schema *configschema.Block, config cty.Value, defaults map[string]cty.Value) ( + cty.Value, tfdiags.Diagnostics) { + return mockValueComposer{}.composeMockValueBySchema(schema, config, defaults) +} + +type mockValueComposer struct { + getMockStringOverride func() string +} + +func (mvc mockValueComposer) getMockString() string { + f := getRandomAlphaNumString + + if mvc.getMockStringOverride != nil { + f = mvc.getMockStringOverride + } + + return f() +} + +func (mvc mockValueComposer) composeMockValueBySchema(schema *configschema.Block, config cty.Value, defaults map[string]cty.Value) (cty.Value, tfdiags.Diagnostics) { + var configMap map[string]cty.Value + var diags tfdiags.Diagnostics + + if !config.IsNull() { + configMap = config.AsValueMap() + } + + impliedTypes := schema.ImpliedType().AttributeTypes() + + mockAttrs, moreDiags := mvc.composeMockValueForAttributes(schema, configMap, defaults) + diags = diags.Append(moreDiags) + if moreDiags.HasErrors() { + return cty.NilVal, diags + } + + mockBlocks, moreDiags := mvc.composeMockValueForBlocks(schema, configMap, defaults) + diags = diags.Append(moreDiags) + if moreDiags.HasErrors() { + return cty.NilVal, diags + } + + mockValues := mockAttrs + for k, v := range mockBlocks { + mockValues[k] = v + } + + for k := range defaults { + if _, ok := impliedTypes[k]; !ok { + diags = diags.Append(tfdiags.WholeContainingBody( + tfdiags.Warning, + fmt.Sprintf("Ignored mock/override field `%v`", k), + "The field is unknown. Please, ensure it is a part of resource definition.", + )) + } + } + + return cty.ObjectVal(mockValues), diags +} + +func (mvc mockValueComposer) composeMockValueForAttributes(schema *configschema.Block, configMap map[string]cty.Value, defaults map[string]cty.Value) (map[string]cty.Value, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + + addPotentialDefaultsWarning := func(key, description string) { + if _, ok := defaults[key]; ok { + diags = diags.Append(tfdiags.WholeContainingBody( + tfdiags.Warning, + fmt.Sprintf("Ignored mock/override field `%v`", key), + description, + )) + } + } + + mockAttrs := make(map[string]cty.Value) + + impliedTypes := schema.ImpliedType().AttributeTypes() + + for k, attr := range schema.Attributes { + // If the value present in configuration - just use it. + if cv, ok := configMap[k]; ok && !cv.IsNull() { + mockAttrs[k] = cv + addPotentialDefaultsWarning(k, "The field is ignored since overriding configuration values is not allowed.") + continue + } + + // Non-computed attributes can't be generated + // so we set them from configuration only. + if !attr.Computed { + mockAttrs[k] = cty.NullVal(attr.Type) + addPotentialDefaultsWarning(k, "The field is ignored since overriding non-computed fields is not allowed.") + continue + } + + // If the attribute is computed and not configured, + // we use provided value from defaults. + if ov, ok := defaults[k]; ok { + typeConformanceErrs := ov.Type().TestConformance(attr.Type) + if len(typeConformanceErrs) == 0 { + mockAttrs[k] = ov + continue + } + + for _, err := range typeConformanceErrs { + diags = diags.Append(tfdiags.WholeContainingBody( + tfdiags.Warning, + fmt.Sprintf("Ignored mock/override field `%v`", k), + fmt.Sprintf("Values provided for override / mock must match resource fields types: %v.", err), + )) + } + } + + // If there's no value in defaults, we generate our own. + v, ok := mvc.getMockValueByType(impliedTypes[k]) + if !ok { + diags = diags.Append(tfdiags.WholeContainingBody( + tfdiags.Error, + "Failed to generate mock value", + fmt.Sprintf("Mock value cannot be generated for dynamic type. Please specify the `%v` field explicitly in the configuration.", k), + )) + continue + } + + mockAttrs[k] = v + } + + return mockAttrs, diags +} + +func (mvc mockValueComposer) composeMockValueForBlocks(schema *configschema.Block, configMap map[string]cty.Value, defaults map[string]cty.Value) (map[string]cty.Value, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + + mockBlocks := make(map[string]cty.Value) + + impliedTypes := schema.ImpliedType().AttributeTypes() + + for k, block := range schema.BlockTypes { + // Checking if the config value really present for the block. + // It should be non-null and non-empty collection. + + configVal, hasConfigVal := configMap[k] + if hasConfigVal && configVal.IsNull() { + hasConfigVal = false + } + + if hasConfigVal && !configVal.IsKnown() { + hasConfigVal = false + } + + if hasConfigVal && configVal.Type().IsCollectionType() && configVal.LengthInt() == 0 { + hasConfigVal = false + } + + defaultVal, hasDefaultVal := defaults[k] + if hasDefaultVal && !defaultVal.Type().IsObjectType() { + hasDefaultVal = false + diags = diags.Append(tfdiags.WholeContainingBody( + tfdiags.Warning, + fmt.Sprintf("Ignored mock/override field `%v`", k), + fmt.Sprintf("Blocks can be overridden only by objects, got `%s`", defaultVal.Type().FriendlyName()), + )) + } + + // We must keep blocks the same as it defined in configuration, + // so provider response validation succeeds later. + if !hasConfigVal { + mockBlocks[k] = block.EmptyValue() + + if hasDefaultVal { + diags = diags.Append(tfdiags.WholeContainingBody( + tfdiags.Warning, + fmt.Sprintf("Ignored mock/override field `%v`", k), + "Cannot overridde block value, because it's not present in configuration.", + )) + } + + continue + } + + var blockDefaults map[string]cty.Value + + if hasDefaultVal { + blockDefaults = defaultVal.AsValueMap() + } + + v, moreDiags := mvc.getMockValueForBlock(impliedTypes[k], configVal, &block.Block, blockDefaults) + diags = append(diags, moreDiags...) + if moreDiags.HasErrors() { + return nil, diags + } + + mockBlocks[k] = v + } + + return mockBlocks, diags +} + +// getMockValueForBlock uses an object from the defaults (overrides) +// to compose each value from the block's inner collection. It recursevily calls +// composeMockValueBySchema to proceed with all the inner attributes and blocks +// the same way so all the nested blocks follow the same logic. +func (mvc mockValueComposer) getMockValueForBlock(targetType cty.Type, configVal cty.Value, block *configschema.Block, defaults map[string]cty.Value) (cty.Value, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + + switch { + case targetType.IsObjectType(): + mockBlockVal, moreDiags := mvc.composeMockValueBySchema(block, configVal, defaults) + diags = diags.Append(moreDiags) + if moreDiags.HasErrors() { + return cty.NilVal, diags + } + + return mockBlockVal, diags + + case targetType.ListElementType() != nil || targetType.SetElementType() != nil: + var mockBlockVals []cty.Value + + var iterator = configVal.ElementIterator() + + for iterator.Next() { + _, blockConfigV := iterator.Element() + + mockBlockVal, moreDiags := mvc.composeMockValueBySchema(block, blockConfigV, defaults) + diags = diags.Append(moreDiags) + if moreDiags.HasErrors() { + return cty.NilVal, diags + } + + mockBlockVals = append(mockBlockVals, mockBlockVal) + } + + if targetType.ListElementType() != nil { + return cty.ListVal(mockBlockVals), diags + } else { + return cty.SetVal(mockBlockVals), diags + } + + case targetType.MapElementType() != nil: + var mockBlockVals = make(map[string]cty.Value) + + var iterator = configVal.ElementIterator() + + for iterator.Next() { + blockConfigK, blockConfigV := iterator.Element() + + mockBlockVal, moreDiags := mvc.composeMockValueBySchema(block, blockConfigV, defaults) + diags = diags.Append(moreDiags) + if moreDiags.HasErrors() { + return cty.NilVal, diags + } + + mockBlockVals[blockConfigK.AsString()] = mockBlockVal + } + + return cty.MapVal(mockBlockVals), diags + + default: + // Shouldn't happen as long as blocks are represented by lists / maps / sets / objs. + return cty.NilVal, diags.Append(tfdiags.WholeContainingBody( + tfdiags.Error, + fmt.Sprintf("Unexpected block type: %v", targetType.FriendlyName()), + "Failed to generate mock value for this block type. Please, report it as an issue at OpenTofu repository, since it's not expected.", + )) + } +} + +// getMockValueByType tries to generate mock cty.Value based on provided cty.Type. +// It will return non-ok response if it encounters dynamic type. +func (mvc mockValueComposer) getMockValueByType(t cty.Type) (cty.Value, bool) { + var v cty.Value + + // just to be sure for cases when the logic below misses something + if t.HasDynamicTypes() { + return cty.Value{}, false + } + + switch { + // primitives + case t.Equals(cty.Number): + v = cty.Zero + case t.Equals(cty.Bool): + v = cty.False + case t.Equals(cty.String): + v = cty.StringVal(mvc.getMockString()) + + // collections + case t.ListElementType() != nil: + v = cty.ListValEmpty(*t.ListElementType()) + case t.MapElementType() != nil: + v = cty.MapValEmpty(*t.MapElementType()) + case t.SetElementType() != nil: + v = cty.SetValEmpty(*t.SetElementType()) + + // structural + case t.IsObjectType(): + objVals := make(map[string]cty.Value) + + // populate the object with mock values + for k, at := range t.AttributeTypes() { + if t.AttributeOptional(k) { + continue + } + + objV, ok := mvc.getMockValueByType(at) + if !ok { + return cty.Value{}, false + } + + objVals[k] = objV + } + + v = cty.ObjectVal(objVals) + case t.IsTupleType(): + v = cty.EmptyTupleVal + + // dynamically typed values are not supported + default: + return cty.Value{}, false + } + + return v, true +} + +func getRandomAlphaNumString() string { + const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890" + + const minLength, maxLength = 4, 16 + + length := rand.Intn(maxLength-minLength) + minLength //nolint:gosec // It doesn't need to be secure. + + b := strings.Builder{} + b.Grow(length) + + for i := 0; i < length; i++ { + b.WriteByte(chars[rand.Intn(len(chars))]) //nolint:gosec // It doesn't need to be secure. + } + + return b.String() +} diff --git a/internal/configs/hcl2shim/mock_value_composer_test.go b/internal/configs/hcl2shim/mock_value_composer_test.go new file mode 100644 index 0000000000..120f2e9b70 --- /dev/null +++ b/internal/configs/hcl2shim/mock_value_composer_test.go @@ -0,0 +1,492 @@ +package hcl2shim + +import ( + "strings" + "testing" + + "github.com/opentofu/opentofu/internal/configs/configschema" + "github.com/zclconf/go-cty/cty" +) + +func TestComposeMockValueBySchema(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + schema *configschema.Block + config cty.Value + defaults map[string]cty.Value + wantVal cty.Value + wantWarning bool + wantError bool + }{ + "diff-props-in-root-attributes": { + schema: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "required-only": { + Type: cty.String, + Required: true, + Optional: false, + Computed: false, + Sensitive: false, + }, + "required-computed": { + Type: cty.String, + Required: true, + Optional: false, + Computed: true, + Sensitive: false, + }, + "optional": { + Type: cty.String, + Required: false, + Optional: true, + Computed: false, + Sensitive: false, + }, + "optional-computed": { + Type: cty.String, + Required: false, + Optional: true, + Computed: true, + Sensitive: false, + }, + "computed-only": { + Type: cty.String, + Required: false, + Optional: false, + Computed: true, + Sensitive: false, + }, + "sensitive-optional": { + Type: cty.String, + Required: false, + Optional: true, + Computed: false, + Sensitive: true, + }, + "sensitive-required": { + Type: cty.String, + Required: true, + Optional: false, + Computed: false, + Sensitive: true, + }, + "sensitive-computed": { + Type: cty.String, + Required: true, + Optional: false, + Computed: true, + Sensitive: true, + }, + }, + }, + config: cty.NilVal, + wantVal: cty.ObjectVal(map[string]cty.Value{ + "required-only": cty.NullVal(cty.String), + "required-computed": cty.StringVal("aaaaaaaa"), + "optional": cty.NullVal(cty.String), + "optional-computed": cty.StringVal("aaaaaaaa"), + "computed-only": cty.StringVal("aaaaaaaa"), + "sensitive-optional": cty.NullVal(cty.String), + "sensitive-required": cty.NullVal(cty.String), + "sensitive-computed": cty.StringVal("aaaaaaaa"), + }), + }, + "diff-props-in-single-block-attributes": { + schema: &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "nested": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "required-only": { + Type: cty.String, + Required: true, + Optional: false, + Computed: false, + Sensitive: false, + }, + "required-computed": { + Type: cty.String, + Required: true, + Optional: false, + Computed: true, + Sensitive: false, + }, + "optional": { + Type: cty.String, + Required: false, + Optional: true, + Computed: false, + Sensitive: false, + }, + "optional-computed": { + Type: cty.String, + Required: false, + Optional: true, + Computed: true, + Sensitive: false, + }, + "computed-only": { + Type: cty.String, + Required: false, + Optional: false, + Computed: true, + Sensitive: false, + }, + "sensitive-optional": { + Type: cty.String, + Required: false, + Optional: true, + Computed: false, + Sensitive: true, + }, + "sensitive-required": { + Type: cty.String, + Required: true, + Optional: false, + Computed: false, + Sensitive: true, + }, + "sensitive-computed": { + Type: cty.String, + Required: true, + Optional: false, + Computed: true, + Sensitive: true, + }, + }, + }, + }, + }, + }, + config: cty.ObjectVal(map[string]cty.Value{ + "nested": cty.ObjectVal(map[string]cty.Value{}), + }), + wantVal: cty.ObjectVal(map[string]cty.Value{ + "nested": cty.ObjectVal(map[string]cty.Value{ + "required-only": cty.NullVal(cty.String), + "required-computed": cty.StringVal("aaaaaaaa"), + "optional": cty.NullVal(cty.String), + "optional-computed": cty.StringVal("aaaaaaaa"), + "computed-only": cty.StringVal("aaaaaaaa"), + "sensitive-optional": cty.NullVal(cty.String), + "sensitive-required": cty.NullVal(cty.String), + "sensitive-computed": cty.StringVal("aaaaaaaa"), + }), + }), + }, + "basic-group-block": { + schema: &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "nested": { + Nesting: configschema.NestingGroup, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "field": { + Type: cty.Number, + Computed: true, + }, + }, + }, + }, + }, + }, + config: cty.ObjectVal(map[string]cty.Value{ + "nested": cty.ObjectVal(map[string]cty.Value{}), + }), + wantVal: cty.ObjectVal(map[string]cty.Value{ + "nested": cty.ObjectVal(map[string]cty.Value{ + "field": cty.NumberIntVal(0), + }), + }), + }, + "basic-list-block": { + schema: &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "nested": { + Nesting: configschema.NestingList, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "field": { + Type: cty.Number, + Computed: true, + }, + }, + }, + }, + }, + }, + config: cty.ObjectVal(map[string]cty.Value{ + "nested": cty.ListVal([]cty.Value{cty.ObjectVal(map[string]cty.Value{})}), + }), + wantVal: cty.ObjectVal(map[string]cty.Value{ + "nested": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "field": cty.NumberIntVal(0), + }), + }), + }), + }, + "basic-set-block": { + schema: &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "nested": { + Nesting: configschema.NestingSet, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "field": { + Type: cty.Number, + Computed: true, + }, + }, + }, + }, + }, + }, + config: cty.ObjectVal(map[string]cty.Value{ + "nested": cty.SetVal([]cty.Value{cty.ObjectVal(map[string]cty.Value{})}), + }), + wantVal: cty.ObjectVal(map[string]cty.Value{ + "nested": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "field": cty.NumberIntVal(0), + }), + }), + }), + }, + "basic-map-block": { + schema: &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "nested": { + Nesting: configschema.NestingMap, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "field": { + Type: cty.Number, + Computed: true, + }, + }, + }, + }, + }, + }, + config: cty.ObjectVal(map[string]cty.Value{ + "nested": cty.MapVal(map[string]cty.Value{ + "somelabel": cty.ObjectVal(map[string]cty.Value{}), + }), + }), + wantVal: cty.ObjectVal(map[string]cty.Value{ + "nested": cty.MapVal(map[string]cty.Value{ + "somelabel": cty.ObjectVal(map[string]cty.Value{ + "field": cty.NumberIntVal(0), + }), + }), + }), + }, + "basic-mocked-attributes": { + schema: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "num": { + Type: cty.Number, + Computed: true, + Optional: true, + }, + "str": { + Type: cty.String, + Computed: true, + Optional: true, + }, + "bool": { + Type: cty.Bool, + Computed: true, + Optional: true, + }, + "obj": { + Type: cty.Object(map[string]cty.Type{ + "fieldNum": cty.Number, + "fieldStr": cty.String, + }), + Computed: true, + Optional: true, + }, + "list": { + Type: cty.List(cty.String), + Computed: true, + Optional: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "nested": { + Nesting: configschema.NestingList, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "num": { + Type: cty.Number, + Computed: true, + Optional: true, + }, + "str": { + Type: cty.String, + Computed: true, + Optional: true, + }, + "bool": { + Type: cty.Bool, + Computed: true, + Optional: true, + }, + "obj": { + Type: cty.Object(map[string]cty.Type{ + "fieldNum": cty.Number, + "fieldStr": cty.String, + }), + Computed: true, + Optional: true, + }, + "list": { + Type: cty.List(cty.String), + Computed: true, + Optional: true, + }, + }, + }, + }, + }, + }, + config: cty.ObjectVal(map[string]cty.Value{ + "nested": cty.ListVal([]cty.Value{cty.ObjectVal(map[string]cty.Value{})}), + }), + wantVal: cty.ObjectVal(map[string]cty.Value{ + "num": cty.NumberIntVal(0), + "str": cty.StringVal("aaaaaaaa"), + "bool": cty.False, + "obj": cty.ObjectVal(map[string]cty.Value{ + "fieldNum": cty.NumberIntVal(0), + "fieldStr": cty.StringVal("aaaaaaaa"), + }), + "list": cty.ListValEmpty(cty.String), + "nested": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "num": cty.NumberIntVal(0), + "str": cty.StringVal("aaaaaaaa"), + "bool": cty.False, + "obj": cty.ObjectVal(map[string]cty.Value{ + "fieldNum": cty.NumberIntVal(0), + "fieldStr": cty.StringVal("aaaaaaaa"), + }), + "list": cty.ListValEmpty(cty.String), + }), + }), + }), + }, + "source-priority": { + schema: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "useConfigValue": { + Type: cty.String, + Computed: true, + Optional: true, + }, + "useDefaultsValue": { + Type: cty.String, + Computed: true, + Optional: true, + }, + "generateMockValue": { + Type: cty.String, + Computed: true, + Optional: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "nested": { + Nesting: configschema.NestingList, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "useConfigValue": { + Type: cty.String, + Computed: true, + Optional: true, + }, + "useDefaultsValue": { + Type: cty.String, + Computed: true, + Optional: true, + }, + "generateMockValue": { + Type: cty.String, + Computed: true, + Optional: true, + }, + }, + }, + }, + }, + }, + config: cty.ObjectVal(map[string]cty.Value{ + "useConfigValue": cty.StringVal("iAmFromConfig"), + "nested": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "useConfigValue": cty.StringVal("iAmFromConfig"), + }), + }), + }), + defaults: map[string]cty.Value{ + "useConfigValue": cty.StringVal("iAmFromDefaults"), + "useDefaultsValue": cty.StringVal("iAmFromDefaults"), + "nested": cty.ObjectVal(map[string]cty.Value{ + "useConfigValue": cty.StringVal("iAmFromDefaults"), + "useDefaultsValue": cty.StringVal("iAmFromDefaults"), + }), + }, + wantVal: cty.ObjectVal(map[string]cty.Value{ + "useConfigValue": cty.StringVal("iAmFromConfig"), + "useDefaultsValue": cty.StringVal("iAmFromDefaults"), + "generateMockValue": cty.StringVal("aaaaaaaa"), + "nested": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "useConfigValue": cty.StringVal("iAmFromConfig"), + "useDefaultsValue": cty.StringVal("iAmFromDefaults"), + "generateMockValue": cty.StringVal("aaaaaaaa"), + }), + }), + }), + wantWarning: true, // ignored value in defaults + }, + } + + const mockStringLength = 8 + + mvc := mockValueComposer{ + getMockStringOverride: func() string { + return strings.Repeat("a", mockStringLength) + }, + } + + for name, test := range tests { + test := test + + t.Run(name, func(t *testing.T) { + t.Parallel() + + gotVal, gotDiags := mvc.composeMockValueBySchema(test.schema, test.config, test.defaults) + switch { + case test.wantError && !gotDiags.HasErrors(): + t.Fatalf("Expected error in diags, but none returned") + + case !test.wantError && gotDiags.HasErrors(): + t.Fatalf("Got unexpected error diags: %v", gotDiags.ErrWithWarnings()) + + case test.wantWarning && len(gotDiags) == 0: + t.Fatalf("Expected warning in diags, but none returned") + + case !test.wantWarning && len(gotDiags) != 0: + t.Fatalf("Got unexpected diags: %v", gotDiags.ErrWithWarnings()) + + case !test.wantVal.RawEquals(gotVal): + t.Fatalf("Got unexpected value: %v", gotVal.GoString()) + } + }) + } +} diff --git a/internal/configs/module.go b/internal/configs/module.go index 15aacd0a2e..b48ab5deb9 100644 --- a/internal/configs/module.go +++ b/internal/configs/module.go @@ -60,6 +60,10 @@ type Module struct { Checks map[string]*Check Tests map[string]*TestFile + + // IsOverridden indicates if the module is being overridden. It's used in + // testing framework to not call the underlying module. + IsOverridden bool } // File describes the contents of a single configuration file. diff --git a/internal/configs/named_values.go b/internal/configs/named_values.go index 5ebc549132..561d7f90de 100644 --- a/internal/configs/named_values.go +++ b/internal/configs/named_values.go @@ -406,6 +406,14 @@ type Output struct { SensitiveSet bool DeclRange hcl.Range + + // IsOverridden indicates if the output is being overridden. It's used in + // testing framework to not evaluate expression and use OverrideValue instead. + IsOverridden bool + // OverrideValue is only valid if IsOverridden is set to true. The value + // should be used instead of evaluated expression. It's possible to have no + // OverrideValue even with IsOverridden is set to true. + OverrideValue *cty.Value } func decodeOutputBlock(block *hcl.Block, override bool) (*Output, hcl.Diagnostics) { diff --git a/internal/configs/resource.go b/internal/configs/resource.go index 726a82bf55..50e5cb7308 100644 --- a/internal/configs/resource.go +++ b/internal/configs/resource.go @@ -12,6 +12,8 @@ import ( "github.com/hashicorp/hcl/v2/gohcl" "github.com/hashicorp/hcl/v2/hclsyntax" hcljson "github.com/hashicorp/hcl/v2/json" + "github.com/zclconf/go-cty/cty" + "github.com/opentofu/opentofu/internal/addrs" "github.com/opentofu/opentofu/internal/configs/hcl2shim" "github.com/opentofu/opentofu/internal/lang" @@ -49,6 +51,14 @@ type Resource struct { // If this is nil, then this resource is essentially public. Container Container + // IsOverridden indicates if the resource is being overridden. It's used in + // testing framework to not call the underlying provider. + IsOverridden bool + // OverrideValues are only valid if IsOverridden is set to true. The values + // should be used to compose mock provider response. It is possible to have + // zero-length OverrideValues even if IsOverridden is set to true. + OverrideValues map[string]cty.Value + DeclRange hcl.Range TypeRange hcl.Range } diff --git a/internal/configs/test_file.go b/internal/configs/test_file.go index 22af7b4286..6201fae6c4 100644 --- a/internal/configs/test_file.go +++ b/internal/configs/test_file.go @@ -11,6 +11,7 @@ import ( "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/gohcl" "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/zclconf/go-cty/cty" "github.com/opentofu/opentofu/internal/addrs" "github.com/opentofu/opentofu/internal/getmodules" @@ -62,9 +63,30 @@ type TestFile struct { // order. Runs []*TestRun + // OverrideResources is a list of resources to be overridden with static values. + // Underlying providers shouldn't be called for overridden resources. + OverrideResources []*OverrideResource + + // OverrideModules is a list of modules to be overridden with static values. + // Underlying modules shouldn't be called. + OverrideModules []*OverrideModule + VariablesDeclRange hcl.Range } +// Validate does a very simple and cursory check across the file blocks to look +// for simple issues we can highlight early on. It doesn't validate nested run blocks. +func (file *TestFile) Validate() tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + + // It's not allowed to have multiple `override_resource`, `override_data` or `override_module` blocks + // declared globally in a file with the same target address so we want to ensure there's no such cases. + diags = diags.Append(checkForDuplicatedOverrideResources(file.OverrideResources)) + diags = diags.Append(checkForDuplicatedOverrideModules(file.OverrideModules)) + + return diags +} + // TestRun represents a single run block within a test file. // // Each run block represents a single OpenTofu command to be executed and a set @@ -125,6 +147,14 @@ type TestRun struct { // run. ExpectFailures []hcl.Traversal + // OverrideResources is a list of resources to be overridden with static values. + // Underlying providers shouldn't be called for overridden resources. + OverrideResources []*OverrideResource + + // OverrideModules is a list of modules to be overridden with static values. + // Underlying modules shouldn't be called. + OverrideModules []*OverrideModule + NameDeclRange hcl.Range VariablesDeclRange hcl.Range DeclRange hcl.Range @@ -135,8 +165,8 @@ type TestRun struct { func (run *TestRun) Validate() tfdiags.Diagnostics { var diags tfdiags.Diagnostics - // For now, we only want to make sure all the ExpectFailure references are - // the correct kind of reference. + // We want to make sure all the ExpectFailure references + // are the correct kind of reference. for _, traversal := range run.ExpectFailures { reference, refDiags := addrs.ParseRefFromTestingScope(traversal) @@ -160,6 +190,11 @@ func (run *TestRun) Validate() tfdiags.Diagnostics { } + // It's not allowed to have multiple `override_resource`, `override_data` or `override_module` blocks + // inside a single run block with the same target address so we want to ensure there's no such cases. + diags = diags.Append(checkForDuplicatedOverrideResources(run.OverrideResources)) + diags = diags.Append(checkForDuplicatedOverrideModules(run.OverrideModules)) + return diags } @@ -193,6 +228,51 @@ type TestRunOptions struct { DeclRange hcl.Range } +const ( + blockNameOverrideResource = "override_resource" + blockNameOverrideData = "override_data" +) + +// OverrideResource contains information about a resource or data block to be overridden. +type OverrideResource struct { + // Target references resource or data block to override. + Target hcl.Traversal + TargetParsed *addrs.ConfigResource + + // Mode indicates if the Target is resource or data block. + Mode addrs.ResourceMode + + // Values represents fields to use as defaults + // if they are not present in configuration. + Values map[string]cty.Value +} + +func (r OverrideResource) getBlockName() string { + switch r.Mode { + case addrs.ManagedResourceMode: + return blockNameOverrideResource + case addrs.DataResourceMode: + return blockNameOverrideData + case addrs.InvalidResourceMode: + return "invalid" + default: + return "invalid" + } +} + +const blockNameOverrideModule = "override_module" + +// OverrideModule contains information about a module to be overridden. +type OverrideModule struct { + // Target references module call to override. + Target hcl.Traversal + TargetParsed addrs.Module + + // Outputs represents fields to use instead + // of the real module call output. + Outputs map[string]cty.Value +} + func loadTestFile(body hcl.Body) (*TestFile, hcl.Diagnostics) { var diags hcl.Diagnostics @@ -211,6 +291,7 @@ func loadTestFile(body hcl.Body) (*TestFile, hcl.Diagnostics) { if !runDiags.HasErrors() { tf.Runs = append(tf.Runs, run) } + case "variables": if tf.Variables != nil { diags = append(diags, &hcl.Diagnostic{ @@ -230,12 +311,35 @@ func loadTestFile(body hcl.Body) (*TestFile, hcl.Diagnostics) { for _, v := range vars { tf.Variables[v.Name] = v.Expr } + case "provider": provider, providerDiags := decodeProviderBlock(block) diags = append(diags, providerDiags...) if provider != nil { tf.Providers[provider.moduleUniqueKey()] = provider } + + case blockNameOverrideResource: + overrideRes, overrideResDiags := decodeOverrideResourceBlock(block, addrs.ManagedResourceMode) + diags = append(diags, overrideResDiags...) + if !overrideResDiags.HasErrors() { + tf.OverrideResources = append(tf.OverrideResources, overrideRes) + } + + case blockNameOverrideData: + overrideData, overrideDataDiags := decodeOverrideResourceBlock(block, addrs.DataResourceMode) + diags = append(diags, overrideDataDiags...) + if !overrideDataDiags.HasErrors() { + tf.OverrideResources = append(tf.OverrideResources, overrideData) + } + + case blockNameOverrideModule: + overrideMod, overrideModDiags := decodeOverrideModuleBlock(block) + diags = append(diags, overrideModDiags...) + if !overrideModDiags.HasErrors() { + tf.OverrideModules = append(tf.OverrideModules, overrideMod) + } + } } @@ -321,6 +425,27 @@ func decodeTestRunBlock(block *hcl.Block) (*TestRun, hcl.Diagnostics) { if !moduleDiags.HasErrors() { r.Module = module } + + case blockNameOverrideResource: + overrideRes, overrideResDiags := decodeOverrideResourceBlock(block, addrs.ManagedResourceMode) + diags = append(diags, overrideResDiags...) + if !overrideResDiags.HasErrors() { + r.OverrideResources = append(r.OverrideResources, overrideRes) + } + + case blockNameOverrideData: + overrideData, overrideDataDiags := decodeOverrideResourceBlock(block, addrs.DataResourceMode) + diags = append(diags, overrideDataDiags...) + if !overrideDataDiags.HasErrors() { + r.OverrideResources = append(r.OverrideResources, overrideData) + } + + case blockNameOverrideModule: + overrideMod, overrideModDiags := decodeOverrideModuleBlock(block) + diags = append(diags, overrideModDiags...) + if !overrideModDiags.HasErrors() { + r.OverrideModules = append(r.OverrideModules, overrideMod) + } } } @@ -537,6 +662,143 @@ func decodeTestRunOptionsBlock(block *hcl.Block) (*TestRunOptions, hcl.Diagnosti return &opts, diags } +func decodeOverrideResourceBlock(block *hcl.Block, mode addrs.ResourceMode) (*OverrideResource, hcl.Diagnostics) { + parseTarget := func(attr *hcl.Attribute) (hcl.Traversal, *addrs.ConfigResource, hcl.Diagnostics) { + traversal, traversalDiags := hcl.AbsTraversalForExpr(attr.Expr) + diags := traversalDiags + if traversalDiags.HasErrors() { + return nil, nil, diags + } + + configRes, configResDiags := addrs.ParseConfigResource(traversal) + diags = append(diags, configResDiags.ToHCL()...) + if configResDiags.HasErrors() { + return nil, nil, diags + } + + return traversal, &configRes, diags + } + + res := &OverrideResource{ + Mode: mode, + } + + content, diags := block.Body.Content(overrideResourceBlockSchema) + + if attr, exists := content.Attributes["target"]; exists { + target, parsed, moreDiags := parseTarget(attr) + res.Target, res.TargetParsed = target, parsed + diags = append(diags, moreDiags...) + } + + if attr, exists := content.Attributes["values"]; exists { + v, moreDiags := parseObjectAttrWithNoVariables(attr) + res.Values, diags = v, append(diags, moreDiags...) + } + + return res, diags +} + +func decodeOverrideModuleBlock(block *hcl.Block) (*OverrideModule, hcl.Diagnostics) { + parseTarget := func(attr *hcl.Attribute) (hcl.Traversal, addrs.Module, hcl.Diagnostics) { + traversal, traversalDiags := hcl.AbsTraversalForExpr(attr.Expr) + diags := traversalDiags + if traversalDiags.HasErrors() { + return nil, nil, diags + } + + target, targetDiags := addrs.ParseModule(traversal) + diags = append(diags, targetDiags.ToHCL()...) + if targetDiags.HasErrors() { + return nil, nil, diags + } + + return traversal, target, diags + } + + mod := &OverrideModule{} + + content, diags := block.Body.Content(overrideModuleBlockSchema) + + if attr, exists := content.Attributes["target"]; exists { + traversal, target, moreDiags := parseTarget(attr) + mod.Target, mod.TargetParsed = traversal, target + diags = append(diags, moreDiags...) + } + + if attr, exists := content.Attributes["outputs"]; exists { + outputs, moreDiags := parseObjectAttrWithNoVariables(attr) + mod.Outputs, diags = outputs, append(diags, moreDiags...) + } + + return mod, diags +} + +func parseObjectAttrWithNoVariables(attr *hcl.Attribute) (map[string]cty.Value, hcl.Diagnostics) { + attrVal, valDiags := attr.Expr.Value(nil) + diags := valDiags + if valDiags.HasErrors() { + return nil, diags + } + + if !attrVal.Type().IsObjectType() { + return nil, append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Object expected", + Detail: fmt.Sprintf("The attribute `%v` must be an object.", attr.Name), + Subject: attr.Range.Ptr(), + }) + } + + return attrVal.AsValueMap(), diags +} + +func checkForDuplicatedOverrideResources(resources []*OverrideResource) hcl.Diagnostics { + var diags hcl.Diagnostics + + overrideResources := make(map[string]struct{}, len(resources)) + for _, res := range resources { + k := res.TargetParsed.String() + + if _, ok := overrideResources[k]; ok { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: fmt.Sprintf("Duplicated `%v` block", res.getBlockName()), + Detail: fmt.Sprintf("It is not allowed to have multiple `%v` blocks with the same target: `%v`.", res.getBlockName(), res.TargetParsed), + Subject: res.Target.SourceRange().Ptr(), + }) + continue + } + + overrideResources[k] = struct{}{} + } + + return diags +} + +func checkForDuplicatedOverrideModules(modules []*OverrideModule) hcl.Diagnostics { + var diags hcl.Diagnostics + + overrideModules := make(map[string]struct{}, len(modules)) + for _, mod := range modules { + k := mod.TargetParsed.String() + + if _, ok := overrideModules[k]; ok { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Duplicated `override_module` block", + Detail: fmt.Sprintf("It is not allowed to have multiple `override_module` blocks with the same target: `%v`.", mod.TargetParsed), + Subject: mod.Target.SourceRange().Ptr(), + }) + continue + } + + overrideModules[k] = struct{}{} + } + + return diags +} + var testFileSchema = &hcl.BodySchema{ Blocks: []hcl.BlockHeaderSchema{ { @@ -550,6 +812,15 @@ var testFileSchema = &hcl.BodySchema{ { Type: "variables", }, + { + Type: blockNameOverrideResource, + }, + { + Type: blockNameOverrideData, + }, + { + Type: blockNameOverrideModule, + }, }, } @@ -572,6 +843,15 @@ var testRunBlockSchema = &hcl.BodySchema{ { Type: "module", }, + { + Type: blockNameOverrideResource, + }, + { + Type: blockNameOverrideData, + }, + { + Type: blockNameOverrideModule, + }, }, } @@ -590,3 +870,31 @@ var testRunModuleBlockSchema = &hcl.BodySchema{ {Name: "version"}, }, } + +//nolint:gochecknoglobals // To follow existing code style. +var overrideResourceBlockSchema = &hcl.BodySchema{ + Attributes: []hcl.AttributeSchema{ + { + Name: "target", + Required: true, + }, + { + Name: "values", + Required: false, + }, + }, +} + +//nolint:gochecknoglobals // To follow existing code style. +var overrideModuleBlockSchema = &hcl.BodySchema{ + Attributes: []hcl.AttributeSchema{ + { + Name: "target", + Required: true, + }, + { + Name: "outputs", + Required: false, + }, + }, +} diff --git a/internal/tofu/graph_builder_apply.go b/internal/tofu/graph_builder_apply.go index d24d2a07bf..afeffe8788 100644 --- a/internal/tofu/graph_builder_apply.go +++ b/internal/tofu/graph_builder_apply.go @@ -196,7 +196,9 @@ func (b *ApplyGraphBuilder) Steps() []GraphTransformer { &CloseProviderTransformer{}, // close the root module - &CloseRootModuleTransformer{}, + &CloseRootModuleTransformer{ + RootConfig: b.Config, + }, // Perform the transitive reduction to make our graph a bit // more understandable if possible (it usually is possible). diff --git a/internal/tofu/graph_builder_eval.go b/internal/tofu/graph_builder_eval.go index 3066baaa51..3610cdb5c2 100644 --- a/internal/tofu/graph_builder_eval.go +++ b/internal/tofu/graph_builder_eval.go @@ -109,7 +109,9 @@ func (b *EvalGraphBuilder) Steps() []GraphTransformer { &CloseProviderTransformer{}, // Close root module - &CloseRootModuleTransformer{}, + &CloseRootModuleTransformer{ + RootConfig: b.Config, + }, // Remove redundant edges to simplify the graph. &TransitiveReductionTransformer{}, diff --git a/internal/tofu/graph_builder_plan.go b/internal/tofu/graph_builder_plan.go index 0434d0c5f9..0ab7e8c873 100644 --- a/internal/tofu/graph_builder_plan.go +++ b/internal/tofu/graph_builder_plan.go @@ -244,7 +244,9 @@ func (b *PlanGraphBuilder) Steps() []GraphTransformer { &CloseProviderTransformer{}, // Close the root module - &CloseRootModuleTransformer{}, + &CloseRootModuleTransformer{ + RootConfig: b.Config, + }, // Perform the transitive reduction to make our graph a bit // more understandable if possible (it usually is possible). diff --git a/internal/tofu/node_module_expand.go b/internal/tofu/node_module_expand.go index 3b48f086ff..d0f86ed752 100644 --- a/internal/tofu/node_module_expand.go +++ b/internal/tofu/node_module_expand.go @@ -149,7 +149,8 @@ func (n *nodeExpandModule) Execute(ctx EvalContext, op walkOperation) (diags tfd // The root module instance also closes any remaining provisioner plugins which // do not have a lifecycle controlled by individual graph nodes. type nodeCloseModule struct { - Addr addrs.Module + Addr addrs.Module + RootConfig *configs.Config } var ( @@ -180,6 +181,19 @@ func (n *nodeCloseModule) Name() string { return n.Addr.String() + " (close)" } +func (n *nodeCloseModule) IsOverridden(addr addrs.Module) bool { + if n.RootConfig == nil { + return false + } + + modConfig := n.RootConfig.Descendent(addr) + if modConfig == nil { + return false + } + + return modConfig.Module.IsOverridden +} + func (n *nodeCloseModule) Execute(ctx EvalContext, op walkOperation) (diags tfdiags.Diagnostics) { if !n.Addr.IsRoot() { return @@ -202,8 +216,9 @@ func (n *nodeCloseModule) Execute(ctx EvalContext, op walkOperation) (diags tfdi } } - // empty child modules are always removed - if len(mod.Resources) == 0 && !mod.Addr.IsRoot() { + // empty non-root modules are removed normally, + // but if the module is being overridden, it should be kept + if len(mod.Resources) == 0 && !mod.Addr.IsRoot() && !n.IsOverridden(mod.Addr.Module()) { delete(state.Modules, modKey) } } diff --git a/internal/tofu/node_module_expand_test.go b/internal/tofu/node_module_expand_test.go index b987fdc1ce..1337aac026 100644 --- a/internal/tofu/node_module_expand_test.go +++ b/internal/tofu/node_module_expand_test.go @@ -46,7 +46,7 @@ func TestNodeCloseModuleExecute(t *testing.T) { ctx := &MockEvalContext{ StateState: state.SyncWrapper(), } - node := nodeCloseModule{addrs.Module{"child"}} + node := nodeCloseModule{Addr: addrs.Module{"child"}} diags := node.Execute(ctx, walkApply) if diags.HasErrors() { t.Fatalf("unexpected error: %s", diags.Err()) @@ -58,7 +58,7 @@ func TestNodeCloseModuleExecute(t *testing.T) { } // the root module should do all the module cleanup - node = nodeCloseModule{addrs.RootModule} + node = nodeCloseModule{Addr: addrs.RootModule} diags = node.Execute(ctx, walkApply) if diags.HasErrors() { t.Fatalf("unexpected error: %s", diags.Err()) @@ -77,7 +77,7 @@ func TestNodeCloseModuleExecute(t *testing.T) { ctx := &MockEvalContext{ StateState: state.SyncWrapper(), } - node := nodeCloseModule{addrs.Module{"child"}} + node := nodeCloseModule{Addr: addrs.Module{"child"}} diags := node.Execute(ctx, walkImport) if diags.HasErrors() { diff --git a/internal/tofu/node_output.go b/internal/tofu/node_output.go index 34d8644123..26379d45a8 100644 --- a/internal/tofu/node_output.go +++ b/internal/tofu/node_output.go @@ -344,11 +344,25 @@ func (n *NodeApplyableOutput) Execute(ctx EvalContext, op walkOperation) (diags // If there was no change recorded, or the recorded change was not wholly // known, then we need to re-evaluate the output if !changeRecorded || !val.IsWhollyKnown() { - // This has to run before we have a state lock, since evaluation also - // reads the state - var evalDiags tfdiags.Diagnostics - val, evalDiags = ctx.EvaluateExpr(n.Config.Expr, cty.DynamicPseudoType, nil) - diags = diags.Append(evalDiags) + switch { + // If the module is not being overridden, we proceed normally + case !n.Config.IsOverridden: + // This has to run before we have a state lock, since evaluation also + // reads the state + var evalDiags tfdiags.Diagnostics + val, evalDiags = ctx.EvaluateExpr(n.Config.Expr, cty.DynamicPseudoType, nil) + diags = diags.Append(evalDiags) + + // If the module is being overridden and we have a value to use, + // we just use it + case n.Config.OverrideValue != nil: + val = *n.Config.OverrideValue + + // If the module is being overridden, but we don't have any value to use, + // we just set it to null + default: + val = cty.NilVal + } // We'll handle errors below, after we have loaded the module. // Outputs don't have a separate mode for validation, so validate diff --git a/internal/tofu/node_resource_abstract_instance.go b/internal/tofu/node_resource_abstract_instance.go index e1a437cf17..72a55854a5 100644 --- a/internal/tofu/node_resource_abstract_instance.go +++ b/internal/tofu/node_resource_abstract_instance.go @@ -273,7 +273,7 @@ func (n *NodeAbstractResourceInstance) writeResourceInstanceStateDeposed(ctx Eva // objects you are intending to write. func (n *NodeAbstractResourceInstance) writeResourceInstanceStateImpl(ctx EvalContext, deposedKey states.DeposedKey, obj *states.ResourceInstanceObject, targetState phaseState) error { absAddr := n.Addr - _, providerSchema, err := getProvider(ctx, n.ResolvedProvider) + _, providerSchema, err := n.getProvider(ctx, n.ResolvedProvider) if err != nil { return err } @@ -416,7 +416,7 @@ func (n *NodeAbstractResourceInstance) planDestroy(ctx EvalContext, currentState // operation. nullVal := cty.NullVal(unmarkedPriorVal.Type()) - provider, _, err := getProvider(ctx, n.ResolvedProvider) + provider, _, err := n.getProvider(ctx, n.ResolvedProvider) if err != nil { return plan, diags.Append(err) } @@ -495,7 +495,7 @@ func (n *NodeAbstractResourceInstance) writeChange(ctx EvalContext, change *plan return nil } - _, providerSchema, err := getProvider(ctx, n.ResolvedProvider) + _, providerSchema, err := n.getProvider(ctx, n.ResolvedProvider) if err != nil { return err } @@ -546,7 +546,7 @@ func (n *NodeAbstractResourceInstance) refresh(ctx EvalContext, deposedKey state } else { log.Printf("[TRACE] NodeAbstractResourceInstance.refresh for %s (deposed object %s)", absAddr, deposedKey) } - provider, providerSchema, err := getProvider(ctx, n.ResolvedProvider) + provider, providerSchema, err := n.getProvider(ctx, n.ResolvedProvider) if err != nil { return state, diags.Append(err) } @@ -681,7 +681,7 @@ func (n *NodeAbstractResourceInstance) plan( var keyData instances.RepetitionData resource := n.Addr.Resource.Resource - provider, providerSchema, err := getProvider(ctx, n.ResolvedProvider) + provider, providerSchema, err := n.getProviderWithPlannedChange(ctx, n.ResolvedProvider, plannedChange) if err != nil { return nil, nil, keyData, diags.Append(err) } @@ -834,6 +834,7 @@ func (n *NodeAbstractResourceInstance) plan( PriorPrivate: priorPrivate, ProviderMeta: metaConfigVal, }) + diags = diags.Append(resp.Diagnostics.InConfigBody(config.Config, n.Addr.String())) if diags.HasErrors() { return nil, nil, keyData, diags @@ -1434,7 +1435,7 @@ func (n *NodeAbstractResourceInstance) readDataSource(ctx EvalContext, configVal config := *n.Config - provider, providerSchema, err := getProvider(ctx, n.ResolvedProvider) + provider, providerSchema, err := n.getProvider(ctx, n.ResolvedProvider) diags = diags.Append(err) if diags.HasErrors() { return newVal, diags @@ -1561,7 +1562,7 @@ func (n *NodeAbstractResourceInstance) providerMetas(ctx EvalContext) (cty.Value var diags tfdiags.Diagnostics metaConfigVal := cty.NullVal(cty.DynamicPseudoType) - _, providerSchema, err := getProvider(ctx, n.ResolvedProvider) + _, providerSchema, err := n.getProvider(ctx, n.ResolvedProvider) if err != nil { return metaConfigVal, diags.Append(err) } @@ -1599,7 +1600,7 @@ func (n *NodeAbstractResourceInstance) planDataSource(ctx EvalContext, checkRule var keyData instances.RepetitionData var configVal cty.Value - _, providerSchema, err := getProvider(ctx, n.ResolvedProvider) + _, providerSchema, err := n.getProvider(ctx, n.ResolvedProvider) if err != nil { return nil, nil, keyData, diags.Append(err) } @@ -1873,7 +1874,7 @@ func (n *NodeAbstractResourceInstance) applyDataSource(ctx EvalContext, planned var diags tfdiags.Diagnostics var keyData instances.RepetitionData - _, providerSchema, err := getProvider(ctx, n.ResolvedProvider) + _, providerSchema, err := n.getProvider(ctx, n.ResolvedProvider) if err != nil { return nil, keyData, diags.Append(err) } @@ -2251,7 +2252,7 @@ func (n *NodeAbstractResourceInstance) apply( return state, diags } - provider, providerSchema, err := getProvider(ctx, n.ResolvedProvider) + provider, providerSchema, err := n.getProvider(ctx, n.ResolvedProvider) if err != nil { return nil, diags.Append(err) } @@ -2340,6 +2341,7 @@ func (n *NodeAbstractResourceInstance) apply( PlannedPrivate: change.Private, ProviderMeta: metaConfigVal, }) + applyDiags := resp.Diagnostics if applyConfig != nil { applyDiags = applyDiags.InConfigBody(applyConfig.Config, n.Addr.String()) @@ -2569,3 +2571,30 @@ func resourceInstancePrevRunAddr(ctx EvalContext, currentAddr addrs.AbsResourceI table := ctx.MoveResults() return table.OldAddr(currentAddr) } + +func (n *NodeAbstractResourceInstance) getProvider(ctx EvalContext, addr addrs.AbsProviderConfig) (providers.Interface, providers.ProviderSchema, error) { + return n.getProviderWithPlannedChange(ctx, addr, nil) +} + +func (n *NodeAbstractResourceInstance) getProviderWithPlannedChange(ctx EvalContext, addr addrs.AbsProviderConfig, plannedChange *plans.ResourceInstanceChange) (providers.Interface, providers.ProviderSchema, error) { + underlyingProvider, schema, err := getProvider(ctx, addr) + if err != nil { + return nil, providers.ProviderSchema{}, err + } + + if n.Config == nil || !n.Config.IsOverridden { + return underlyingProvider, schema, nil + } + + providerForTest := providerForTest{ + internal: underlyingProvider, + schema: schema, + overrideValues: n.Config.OverrideValues, + } + + if plannedChange != nil { + providerForTest.plannedChange = &plannedChange.After + } + + return providerForTest, schema, nil +} diff --git a/internal/tofu/provider_for_test_framework.go b/internal/tofu/provider_for_test_framework.go new file mode 100644 index 0000000000..ea7b6cbdc4 --- /dev/null +++ b/internal/tofu/provider_for_test_framework.go @@ -0,0 +1,131 @@ +// Copyright (c) The OpenTofu Authors +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2023 HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package tofu + +import ( + "github.com/opentofu/opentofu/internal/addrs" + "github.com/opentofu/opentofu/internal/configs/hcl2shim" + "github.com/opentofu/opentofu/internal/providers" + "github.com/zclconf/go-cty/cty" +) + +var _ providers.Interface = providerForTest{} + +// providerForTest is a wrapper around real provider to allow certain resources to be overridden +// for testing framework. Currently, it's used in NodeAbstractResourceInstance only in a format +// of one time use. It handles overrideValues and plannedChange for a single resource instance +// (i.e. by certain address). +// TODO: providerForTest should be extended to handle mock providers implementation with per-type +// mocking. It will allow providerForTest to be used for both overrides and full mocking. +// In such scenario, overrideValues should be extended to handle per-type values and plannedChange +// should contain per PlanResourceChangeRequest cache to produce the same plan result +// for the same PlanResourceChangeRequest. +type providerForTest struct { + // It's not embedded to make it safer to extend providers.Interface + // without silently breaking providerForTest functionality. + internal providers.Interface + schema providers.ProviderSchema + + overrideValues map[string]cty.Value + plannedChange *cty.Value +} + +func (p providerForTest) ReadResource(r providers.ReadResourceRequest) providers.ReadResourceResponse { + var resp providers.ReadResourceResponse + + resSchema, _ := p.schema.SchemaForResourceType(addrs.ManagedResourceMode, r.TypeName) + + resp.NewState, resp.Diagnostics = hcl2shim.ComposeMockValueBySchema(resSchema, r.ProviderMeta, p.overrideValues) + return resp +} + +func (p providerForTest) PlanResourceChange(r providers.PlanResourceChangeRequest) providers.PlanResourceChangeResponse { + if r.Config.IsNull() { + return providers.PlanResourceChangeResponse{ + PlannedState: r.ProposedNewState, // null + } + } + + if p.plannedChange != nil { + return providers.PlanResourceChangeResponse{ + PlannedState: *p.plannedChange, + } + } + + resSchema, _ := p.schema.SchemaForResourceType(addrs.ManagedResourceMode, r.TypeName) + + var resp providers.PlanResourceChangeResponse + + resp.PlannedState, resp.Diagnostics = hcl2shim.ComposeMockValueBySchema(resSchema, r.Config, p.overrideValues) + + return resp +} + +func (p providerForTest) ApplyResourceChange(r providers.ApplyResourceChangeRequest) providers.ApplyResourceChangeResponse { + return providers.ApplyResourceChangeResponse{ + NewState: r.PlannedState, + } +} + +func (p providerForTest) ReadDataSource(r providers.ReadDataSourceRequest) providers.ReadDataSourceResponse { + resSchema, _ := p.schema.SchemaForResourceType(addrs.DataResourceMode, r.TypeName) + + var resp providers.ReadDataSourceResponse + + resp.State, resp.Diagnostics = hcl2shim.ComposeMockValueBySchema(resSchema, r.Config, p.overrideValues) + + return resp +} + +// Calling the internal provider ensures providerForTest has the same behaviour as if +// it wasn't overridden. Some of these functions should be changed in the future to +// support mock_provider (e.g. ConfigureProvider should do nothing), mock_resource and +// mock_data. The only exception is ImportResourceState, which panics if called via providerForTest +// because importing is not supported in testing framework. + +func (p providerForTest) GetProviderSchema() providers.GetProviderSchemaResponse { + return p.internal.GetProviderSchema() +} + +func (p providerForTest) ValidateProviderConfig(r providers.ValidateProviderConfigRequest) providers.ValidateProviderConfigResponse { + return p.internal.ValidateProviderConfig(r) +} + +func (p providerForTest) ValidateResourceConfig(r providers.ValidateResourceConfigRequest) providers.ValidateResourceConfigResponse { + return p.internal.ValidateResourceConfig(r) +} + +func (p providerForTest) ValidateDataResourceConfig(r providers.ValidateDataResourceConfigRequest) providers.ValidateDataResourceConfigResponse { + return p.internal.ValidateDataResourceConfig(r) +} + +func (p providerForTest) UpgradeResourceState(r providers.UpgradeResourceStateRequest) providers.UpgradeResourceStateResponse { + return p.internal.UpgradeResourceState(r) +} + +func (p providerForTest) ConfigureProvider(r providers.ConfigureProviderRequest) providers.ConfigureProviderResponse { + return p.internal.ConfigureProvider(r) +} + +func (p providerForTest) Stop() error { + return p.internal.Stop() +} + +func (p providerForTest) GetFunctions() providers.GetFunctionsResponse { + return p.internal.GetFunctions() +} + +func (p providerForTest) CallFunction(r providers.CallFunctionRequest) providers.CallFunctionResponse { + return p.internal.CallFunction(r) +} + +func (p providerForTest) Close() error { + return p.internal.Close() +} + +func (p providerForTest) ImportResourceState(providers.ImportResourceStateRequest) providers.ImportResourceStateResponse { + panic("Importing is not supported in testing context. providerForTest must not be used to call ImportResourceState") +} diff --git a/internal/tofu/transform_config.go b/internal/tofu/transform_config.go index 042288199e..169c3ea0e9 100644 --- a/internal/tofu/transform_config.go +++ b/internal/tofu/transform_config.go @@ -71,6 +71,12 @@ func (t *ConfigTransformer) transform(g *Graph, config *configs.Config, generate return nil } + // If the module is being overridden, do nothing. We don't want to create anything + // from the underlying module. + if config.Module.IsOverridden { + return nil + } + // Add our resources if err := t.transformSingle(g, config, generateConfigPath); err != nil { return err diff --git a/internal/tofu/transform_root.go b/internal/tofu/transform_root.go index c4e3284f3a..9d669b3350 100644 --- a/internal/tofu/transform_root.go +++ b/internal/tofu/transform_root.go @@ -6,6 +6,7 @@ package tofu import ( + "github.com/opentofu/opentofu/internal/configs" "github.com/opentofu/opentofu/internal/dag" ) @@ -62,11 +63,15 @@ func (n graphNodeRoot) Name() string { } // CloseRootModuleTransformer is a GraphTransformer that adds a root to the graph. -type CloseRootModuleTransformer struct{} +type CloseRootModuleTransformer struct { + RootConfig *configs.Config +} func (t *CloseRootModuleTransformer) Transform(g *Graph) error { // close the root module - closeRoot := &nodeCloseModule{} + closeRoot := &nodeCloseModule{ + RootConfig: t.RootConfig, + } g.Add(closeRoot) // since this is closing the root module, make it depend on everything in diff --git a/website/docs/cli/commands/test/examples/override_module/bucket_meta/main.tf b/website/docs/cli/commands/test/examples/override_module/bucket_meta/main.tf new file mode 100644 index 0000000000..16511da451 --- /dev/null +++ b/website/docs/cli/commands/test/examples/override_module/bucket_meta/main.tf @@ -0,0 +1,13 @@ +data "local_file" "bucket_name" { + filename = "bucket_name.txt" +} + +output "name" { + value = data.local_file.bucket_name.content +} + +output "tags" { + value = { + Environment = "Dev" + } +} diff --git a/website/docs/cli/commands/test/examples/override_module/main.tf b/website/docs/cli/commands/test/examples/override_module/main.tf new file mode 100644 index 0000000000..06d94209d1 --- /dev/null +++ b/website/docs/cli/commands/test/examples/override_module/main.tf @@ -0,0 +1,12 @@ +module "bucket_meta" { + source = "./bucket_meta" +} + +provider "aws" { + region = "us-east-2" +} + +resource "aws_s3_bucket" "test" { + bucket = module.bucket_meta.name + tags = module.bucket_meta.tags +} diff --git a/website/docs/cli/commands/test/examples/override_module/main.tftest.hcl b/website/docs/cli/commands/test/examples/override_module/main.tftest.hcl new file mode 100644 index 0000000000..ca3b53d36a --- /dev/null +++ b/website/docs/cli/commands/test/examples/override_module/main.tftest.hcl @@ -0,0 +1,32 @@ +// All the module configuration will be ignored for this +// module call. Instead, the `outputs` object will be used +// to populate module outputs. +override_module { + target = module.bucket_meta + outputs = { + name = "test" + tags = { + Environment = "Test Env" + } + } +} + +// Test if the bucket name is correctly passed to the aws_s3_bucket +// resource from the module call. +run "test" { + // S3 bucket will not be created in AWS for this run, + // but it's available to use in both tests and configuration. + override_resource { + target = aws_s3_bucket.test + } + + assert { + condition = aws_s3_bucket.test.bucket == "test" + error_message = "Incorrect bucket name: ${aws_s3_bucket.test.bucket}" + } + + assert { + condition = aws_s3_bucket.test.tags["Environment"] == "Test Env" + error_message = "Incorrect `Environment` tag: ${aws_s3_bucket.test.tags["Environment"]}" + } +} diff --git a/website/docs/cli/commands/test/examples/override_resource/main.tf b/website/docs/cli/commands/test/examples/override_resource/main.tf new file mode 100644 index 0000000000..95c35083a3 --- /dev/null +++ b/website/docs/cli/commands/test/examples/override_resource/main.tf @@ -0,0 +1,11 @@ +data "local_file" "bucket_name" { + filename = "bucket_name.txt" +} + +provider "aws" { + region = "us-east-2" +} + +resource "aws_s3_bucket" "test" { + bucket = data.local_file.bucket_name.content +} diff --git a/website/docs/cli/commands/test/examples/override_resource/main.tftest.hcl b/website/docs/cli/commands/test/examples/override_resource/main.tftest.hcl new file mode 100644 index 0000000000..39cc9a1ae9 --- /dev/null +++ b/website/docs/cli/commands/test/examples/override_resource/main.tftest.hcl @@ -0,0 +1,25 @@ +// This data source will not be called for any run +// in this `.tftest.hcl` file. Instead, `values` object +// will be used to populate `content` attribute. Other +// attributes and blocks will be automatically generated. +override_data { + target = data.local_file.bucket_name + values = { + content = "test" + } +} + +// Test if the bucket name is correctly passed to the aws_s3_bucket +// resource from the local file. +run "test" { + // S3 bucket will not be created in AWS for this run, + // but it's available to use in both tests and configuration. + override_resource { + target = aws_s3_bucket.test + } + + assert { + condition = aws_s3_bucket.test.bucket == "test" + error_message = "Incorrect bucket name: ${aws_s3_bucket.test.bucket}" + } +} diff --git a/website/docs/cli/commands/test/index.mdx b/website/docs/cli/commands/test/index.mdx index ce7e57dbd2..da2fc341fe 100644 --- a/website/docs/cli/commands/test/index.mdx +++ b/website/docs/cli/commands/test/index.mdx @@ -27,6 +27,11 @@ import ExpectFailureVariablesMain from '!!raw-loader!./examples/expect_failures_ import ExpectFailureVariablesTest from '!!raw-loader!./examples/expect_failures_variables/main.tftest.hcl' import ExpectFailureResourcesMain from '!!raw-loader!./examples/expect_failures_resources/main.tf' import ExpectFailureResourcesTest from '!!raw-loader!./examples/expect_failures_resources/main.tftest.hcl' +import OverrideResourceMain from '!!raw-loader!./examples/override_resource/main.tf' +import OverrideResourceTest from '!!raw-loader!./examples/override_resource/main.tftest.hcl' +import OverrideModuleMain from '!!raw-loader!./examples/override_module/main.tf' +import OverrideModuleTest from '!!raw-loader!./examples/override_module/main.tftest.hcl' +import OverrideModuleBucketMeta from '!!raw-loader!./examples/override_module/bucket_meta/main.tf' # Command: test @@ -127,6 +132,9 @@ A test file consists of: * A **[`variables` block](#the-variables-and-runvariables-blocks)** (optional): define variables for all tests in the current file. * The **[`provider` blocks](#the-providers-block)** (optional): define the providers to be used for the tests. +* The **[`override_resource` block](#the-override_resource-and-override_data-blocks)** (optional): defines a resource to be overridden. +* The **[`override_data` block](#the-override_resource-and-override_data-blocks)** (optional): defines a data source to be overridden. +* The **[`override_module` block](#the-override_module-block)** (optional): defines a module call to be overridden. ### The `run` block @@ -144,6 +152,9 @@ A `run` block consists of the following elements: | [`command`](#the-runcommand-setting-and-the-runplan_options-block) | `plan` or `apply` | Defines the command which OpenTofu will execute, `plan` or `apply`. Defaults to `apply`. | | [`plan_options`](#the-runcommand-setting-and-the-runplan_options-block) | block | Options for the `plan` or `apply` operation. | | [`providers`](#the-providers-block) | object | Aliases for providers. | +| [`override_resource`](#the-override_resource-and-override_data-blocks) | block | Defines a resource to be overridden for the run. | +| [`override_data`](#the-override_resource-and-override_data-blocks) | block | Defines a data source to be overridden for the run. | +| [`override_module`](#the-override_module-block) | block | Defines a module call to be overridden for the run. | ### The `run.assert` block @@ -374,3 +385,94 @@ of the file. {ProviderAliasMain} + +### The `override_resource` and `override_data` blocks + +In some cases you may want to test your infrastructure with certain resources or data sources being overridden. +You can use the `override_resource` or `override_data` blocks to skip creation and retrieval of these resources or data sources using the real provider. +Instead, OpenTofu will automatically generate all computed attributes and blocks to be used in tests. + +These blocks consist of the following elements: + +| Name | Type | Description | +|:--------:|:------------:|------------------------------------------------------------------------------------------------------------------------| +| target | reference | Required. Address of the target resource or data source to be overridden. | +| values | object | Custom values for computed attributes and blocks to be used instead of automatically generated. | + +You can use `override_resource` or `override_data` blocks for the whole test file or inside a single `run` block. The latter takes precedence if both specified for the same `target`. + +In the example below, we test if the bucket name is correctly passed to the resource without actually creating it: + + + + {OverrideResourceTest} + + + {OverrideResourceMain} + + + +:::warning Limitation + +You cannot use `override_resource` or `override_data` with a single instance of a resource or data source. +Each instance of a resource or data source must be overridden. + +::: + +#### Automatically generated values + +Overriding resources and data sources requires OpenTofu to automatically generate computed attributes without calling respective providers. +When generating these values, OpenTofu cannot follow custom provider logic, so it uses simple rules based on value type: + +| Attribute type | Generated value | +|:------------------:|---------------------------------------------------------------------| +| number | `0` | +| bool | `false` | +| string | A random alpha-numeric string. | +| list | An empty list. | +| map | An empty map. | +| set | An empty set. | +| object | An object with its fields populated by the same logic recursevily. | +| tuple | An empty tuple. | + +:::tip Note + +You can set custom values to use instead of automatically generated ones via `values` field in both `override_resource` and `override_data` blocks. +Keep in mind, it's only possible for computed attributes and configuration values cannot be changed. + +::: + +### The `override_module` block + +In some cases you may want to test your infrastructure with certain module calls being overridden. +You can use the `override_module` block to ignore all the configuration provided by called module. +In this case, OpenTofu will use custom values specified in the `override_module` block as module outputs. + +The block consist of the following elements: + +| Name | Type | Description | +|:--------:|:------------:|------------------------------------------------------------------------------------------------------------------------| +| target | reference | Required. Address of the target module call to be overridden. | +| outputs | object | Values to be used as module call outputs. If an output is not specified, OpenTofu will set it to `null` by default. | + +You can use `override_module` block for the whole test file or inside a single `run` block. The latter takes precedence if both specified for the same `target`. + +In the example below, we test if the bucket name is correctly passed from the module without actually calling it: + + + + {OverrideModuleTest} + + + {OverrideModuleMain} + + + {OverrideModuleBucketMeta} + + + +:::warning Limitation + +You cannot use `override_module` with a single instance of a module call. Each instance of a module call must be overridden. + +:::