From 8fd25f334643082e1fc4609a012ca97bb1980e08 Mon Sep 17 00:00:00 2001 From: Oleksandr Levchenkov Date: Tue, 10 Dec 2024 17:23:56 +0200 Subject: [PATCH] [BACKPORT] add simulated state serialization between tofu test runs (#2274) (#2276) Signed-off-by: ollevche --- CHANGELOG.md | 2 + internal/command/test.go | 43 +++++++++++++++++++ internal/command/test_test.go | 4 ++ .../test/destroyed_mod_outputs/main.tf | 9 ++++ .../destroyed_mod_outputs/main.tftest.hcl | 21 +++++++++ .../test/destroyed_mod_outputs/mod/main.tf | 11 +++++ 6 files changed, 90 insertions(+) create mode 100644 internal/command/testdata/test/destroyed_mod_outputs/main.tf create mode 100644 internal/command/testdata/test/destroyed_mod_outputs/main.tftest.hcl create mode 100644 internal/command/testdata/test/destroyed_mod_outputs/mod/main.tf diff --git a/CHANGELOG.md b/CHANGELOG.md index 9aea38e87e..69e0aae895 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## 1.8.8 (unreleased) +BUG FIXES: +* `tofu test` now removes outputs of destroyed modules between different test runs. ([#2274](https://github.com/opentofu/opentofu/pull/2274)) ## 1.8.7 diff --git a/internal/command/test.go b/internal/command/test.go index c11ea22376..24f3f39219 100644 --- a/internal/command/test.go +++ b/internal/command/test.go @@ -6,6 +6,7 @@ package command import ( + "bytes" "context" "fmt" "log" @@ -28,6 +29,7 @@ import ( "github.com/opentofu/opentofu/internal/moduletest" "github.com/opentofu/opentofu/internal/plans" "github.com/opentofu/opentofu/internal/states" + "github.com/opentofu/opentofu/internal/states/statefile" "github.com/opentofu/opentofu/internal/tfdiags" "github.com/opentofu/opentofu/internal/tofu" ) @@ -455,6 +457,26 @@ func (runner *TestFileRunner) ExecuteTestFile(file *moduletest.File) { state, updatedState := runner.ExecuteTestRun(run, file, runner.States[key].State, config) if updatedState { + var err error + + // We need to simulate state serialization between multiple runs + // due to its side effects. One of such side effects is removal + // of destroyed non-root module outputs. This is not handled + // during graph walk since those values are not stored in the + // state file. This is more of a weird workaround instead of a + // proper fix, unfortunately. + state, err = simulateStateSerialization(state) + if err != nil { + run.Diagnostics = run.Diagnostics.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Failure during state serialization", + Detail: err.Error(), + }) + + // We cannot reuse state later so that's a hard stop. + return + } + // Only update the most recent run and state if the state was // actually updated by this change. We want to use the run that // most recently updated the tracked state as the cleanup @@ -1261,3 +1283,24 @@ func (runner *TestFileRunner) prepareInputVariablesForAssertions(config *configs config.Module.Variables = currentVars }, diags } + +// simulateStateSerialization takes a state, serializes it, deserializes it +// and then returns. This is useful for state writing side effects without +// actually writing a state file. +func simulateStateSerialization(state *states.State) (*states.State, error) { + buff := &bytes.Buffer{} + + f := statefile.New(state, "", 0) + + err := statefile.Write(f, buff, encryption.StateEncryptionDisabled()) + if err != nil { + return nil, fmt.Errorf("writing state to buffer: %w", err) + } + + f, err = statefile.Read(buff, encryption.StateEncryptionDisabled()) + if err != nil { + return nil, fmt.Errorf("reading state from buffer: %w", err) + } + + return f.State, nil +} diff --git a/internal/command/test_test.go b/internal/command/test_test.go index 99e69e4d18..309571ea82 100644 --- a/internal/command/test_test.go +++ b/internal/command/test_test.go @@ -820,6 +820,10 @@ func TestTest_Modules(t *testing.T) { expected: "main.tftest.hcl... pass\n run \"setup\"... pass\n run \"test\"... pass\n\nSuccess! 2 passed, 0 failed.\n", code: 0, }, + "destroyed_mod_outputs": { + expected: "main.tftest.hcl... pass\n run \"first_apply\"... pass\n run \"second_apply\"... pass\n\nSuccess! 2 passed, 0 failed.\n", + code: 0, + }, } for name, tc := range tcs { diff --git a/internal/command/testdata/test/destroyed_mod_outputs/main.tf b/internal/command/testdata/test/destroyed_mod_outputs/main.tf new file mode 100644 index 0000000000..d1777206e6 --- /dev/null +++ b/internal/command/testdata/test/destroyed_mod_outputs/main.tf @@ -0,0 +1,9 @@ +variable "numbers" { + type = set(string) +} + +module "mod" { + source = "./mod" + for_each = var.numbers + val = each.key +} diff --git a/internal/command/testdata/test/destroyed_mod_outputs/main.tftest.hcl b/internal/command/testdata/test/destroyed_mod_outputs/main.tftest.hcl new file mode 100644 index 0000000000..4a0b5187b2 --- /dev/null +++ b/internal/command/testdata/test/destroyed_mod_outputs/main.tftest.hcl @@ -0,0 +1,21 @@ +run "first_apply" { + variables { + numbers = [ "a", "b" ] + } + + assert { + condition = length(module.mod) == 2 + error_message = "Amount of module outputs is wrong" + } +} + +run "second_apply" { + variables { + numbers = [ "c", "d" ] + } + + assert { + condition = length(module.mod) == 2 + error_message = "Amount of module outputs is wrong (persisted outputs?)" + } +} diff --git a/internal/command/testdata/test/destroyed_mod_outputs/mod/main.tf b/internal/command/testdata/test/destroyed_mod_outputs/mod/main.tf new file mode 100644 index 0000000000..c0fabc0c6a --- /dev/null +++ b/internal/command/testdata/test/destroyed_mod_outputs/mod/main.tf @@ -0,0 +1,11 @@ +variable "val" { +} + +output "val" { + value = "${var.val}_${test_resource.resource.id}" +} + +resource "test_resource" "resource" { + id = "598318e0" + value = var.val +}