mirror of https://github.com/hashicorp/terraform
Implement controlling destroy functionality within Terraform Test (#37359)
* Add ability to parse backend blocks present in a test file's run blocks, validate configuration (#36541)
* Add ability to parse backend blocks from a run block
* Add validation to avoid multiple backend blocks across run blocks that use the same internal state file. Update tests.
* Add validation to avoid multiple backend blocks within a single run block. Update tests.
* Remove use of quotes in diagnostic messages
* Add validation to avoid backend blocks being used in plan run blocks. Update tests.
* Correct local backend blocks in new test fixtures
* Add test to show that different test files can use same backend block for same state key.
* Add validation to enforce state-storage backend types are used
* Remove TODO comment
We only need to consider one file at a time when checking if a state_key already has a backend associated with it; parallelism in `terraform test` is scoped down to individual files.
* Add validation to assert that the backend block must be in the first apply command for an internal state
* Consolidate backend block validation inside a single if statement
* Add initial version of validation that ensures a backend isn't re-used within a file
* Explicitly set the state_key at the point of parsing the config
TODO: What should be done with method (moduletest.Run).GetStateKey?
* Update test fixture now that reusing backend configs has been made invalid
* Add automated test showing validation of reused configuration blocks
* Skip test due to flakiness, minor change to test config naming
* Update test so it tolerates non-deterministic order run blocks are evaluated in
* Remove unnecessary value assignment to r.StateKey
* Replace use of GetStateKey() with accessing the state key that's now set during test config parsing
* Fix bug so that run blocks using child modules get the correct state key set at parsing time
* Update acceptance test to also cover scenario where root and child module state keys are in use
* Update test name
* Add newline to regex
* Ensure consistent place where repeat backend error is raised from
* Write leftover test state(s) to file (#36614)
* Add additional validation that the backend used in a run is a supported type (#36648)
* Prevent test run when leftover state data is present (#36685)
* `test`: Set the initial state for a state files from a backend, allow the run that defines a backend to write state to the backend (#36646)
* Allow use of backend block to set initial state for a state key
* Note about alternative place to keep 'backend factories'
* Allow the run block defining the backend to write state to it
* Fix rebase
* Change to accessing backend init functions via ContextOpts
* Add tests demonstrating how runs containing backend blocks use and update persisted state
* Fix test fixture
* Address test failure due to trouble opening the state file
This problem doesn't happen on MacOS, so I assume is due to the Linux environment of GitHub runners.
* Fix issue with paths properly
I hope
* Fix defect in test assertion
* Pivot back to approach introduced in 4afc3d7
* Let failing tests write to persistent state, add test case covering that.
I split the acceptance tests into happy/unhappy paths for this, which required some of the helper functions' declarations to be raised up to package-level.
* Change how we update internal state files, so that information about the associated backend is never lost
* Fix UpdateStateFile
* Ensure that the states map set by TestStateTransformer associates a backend with the correct run.
* Misc spelling fixes in comments and a log
* Replace state get/set functions with existing helpers (#36747)
* Replace state get/set functions with existing helpers
* Compare to string representation of state
* Compare to string representation of state
* Terraform Test: Allow skipping cleanup of entire test file or individual run blocks (#36729)
* Add validation to enforce skip_cleanup=false cannot be used with backend blocks (#36857)
* Integrate use of backend blocks in tests with skip_cleanup feature (#36848)
* Fix nil pointer error, update test to not be table-driven
* Make using a backend block implicitly set skip_cleanup to true
* Stop state artefacts being created when a backend is in use and no cleanup errors have occurred
* Return diagnostics so calling code knows if cleanup experienced issues or not
* Update tests to show that when cleanup fails a state artefact is created
* Add comment about why diag not returned
* Bug fix - actually pull in the state from the state manager!
* Split and simplify (?) tests to show the backend block can create and/or reuse prior state
* Update test to use new fixtures, assert about state artefact. Fix nil pointer
* Update test fixture in use, add guardrail for flakiness of forced error during cleanup
* Refactor so resource ID set in only one place
* Add documentation for using a `backend` block during `test` (#36832)
* Add backend as a documented block in a run block
* Add documentation about backend blocks in run blocks.
* Make the relationship between backends and state keys more clear, other improvements
* More test documentation (#36838)
* Terraform Test: cleanup command (#36847)
* Allow cleanup of states that depend on prior runs outputs (#36902)
* terraform test: refactor graph edge calculation
* create fake run block nodes during cleanup operation
* tidy up TODOs
* fix tests
* remove old changes
* Update internal/moduletest/graph/node_state_cleanup.go
Co-authored-by: Samsondeen <40821565+dsa0x@users.noreply.github.com>
* Improve diagnostics around skip_cleanup conflicts (#37385)
* Improve diagnostics around skip_cleanup conflicts
* remove unused dynamic node
* terraform test: refactor manifest file for simplicity (#37412)
* test: refactor apply and plan functions so no run block is needed
* terraform test: write and load state manifest files
* Terraform Test: Allow skipping cleanup of entire test file or individual run blocks (#36729)
* terraform test: add support for skip_cleanup attr
* terraform test: add cleanup command
* terraform test: add backend blocks
* pause
* fix tests
* remove commented code
* terraform test: make controlling destroy functionality experimental (#37419)
* address comments
* Update internal/moduletest/graph/node_state_cleanup.go
Co-authored-by: Samsondeen <40821565+dsa0x@users.noreply.github.com>
---------
Co-authored-by: Samsondeen <40821565+dsa0x@users.noreply.github.com>
* add experimental changelog entries
---------
Co-authored-by: Sarah French <15078782+SarahFrench@users.noreply.github.com>
Co-authored-by: Samsondeen <40821565+dsa0x@users.noreply.github.com>
Co-authored-by: Samsondeen Dare <samsondeen.dare@hashicorp.com>
pull/37589/head
parent
e315a07c71
commit
551ba2e525
@ -0,0 +1,145 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
backendInit "github.com/hashicorp/terraform/internal/backend/init"
|
||||
"github.com/hashicorp/terraform/internal/backend/local"
|
||||
"github.com/hashicorp/terraform/internal/logging"
|
||||
"github.com/hashicorp/terraform/internal/moduletest"
|
||||
"github.com/hashicorp/terraform/internal/tfdiags"
|
||||
)
|
||||
|
||||
// TestCleanupCommand is a command that cleans up left-over resources created
|
||||
// during Terraform test runs. It basically runs the test command in cleanup mode.
|
||||
type TestCleanupCommand struct {
|
||||
Meta
|
||||
}
|
||||
|
||||
func (c *TestCleanupCommand) Help() string {
|
||||
helpText := `
|
||||
Usage: terraform [global options] test cleanup [options]
|
||||
|
||||
Cleans up left-over resources in states that were created during Terraform test runs.
|
||||
|
||||
By default, this command ignores the skip_cleanup attributes in the manifest
|
||||
file. Use the -repair flag to override this behavior, which will ensure that
|
||||
resources that were intentionally left-over are exempt from cleanup.
|
||||
|
||||
Options:
|
||||
|
||||
-repair Overrides the skip_cleanup attribute in the manifest
|
||||
file and attempts to clean up all resources.
|
||||
|
||||
-no-color If specified, output won't contain any color.
|
||||
|
||||
-verbose Print detailed output during the cleanup process.
|
||||
`
|
||||
return strings.TrimSpace(helpText)
|
||||
}
|
||||
|
||||
func (c *TestCleanupCommand) Synopsis() string {
|
||||
return "Clean up left-over resources created during Terraform test runs"
|
||||
}
|
||||
|
||||
func (c *TestCleanupCommand) Run(rawArgs []string) int {
|
||||
setup, diags := c.setupTestExecution(moduletest.CleanupMode, "test cleanup", rawArgs)
|
||||
if diags.HasErrors() {
|
||||
return 1
|
||||
}
|
||||
|
||||
args := setup.Args
|
||||
view := setup.View
|
||||
config := setup.Config
|
||||
variables := setup.Variables
|
||||
testVariables := setup.TestVariables
|
||||
opts := setup.Opts
|
||||
|
||||
// We have two levels of interrupt here. A 'stop' and a 'cancel'. A 'stop'
|
||||
// is a soft request to stop. We'll finish the current test, do the tidy up,
|
||||
// but then skip all remaining tests and run blocks. A 'cancel' is a hard
|
||||
// request to stop now. We'll cancel the current operation immediately
|
||||
// even if it's a delete operation, and we won't clean up any infrastructure
|
||||
// if we're halfway through a test. We'll print details explaining what was
|
||||
// stopped so the user can do their best to recover from it.
|
||||
|
||||
runningCtx, done := context.WithCancel(context.Background())
|
||||
stopCtx, stop := context.WithCancel(runningCtx)
|
||||
cancelCtx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
runner := &local.TestSuiteRunner{
|
||||
BackendFactory: backendInit.Backend,
|
||||
Config: config,
|
||||
// The GlobalVariables are loaded from the
|
||||
// main configuration directory
|
||||
// The GlobalTestVariables are loaded from the
|
||||
// test directory
|
||||
GlobalVariables: variables,
|
||||
GlobalTestVariables: testVariables,
|
||||
TestingDirectory: args.TestDirectory,
|
||||
Opts: opts,
|
||||
View: view,
|
||||
Stopped: false,
|
||||
Cancelled: false,
|
||||
StoppedCtx: stopCtx,
|
||||
CancelledCtx: cancelCtx,
|
||||
Filter: args.Filter,
|
||||
Verbose: args.Verbose,
|
||||
Repair: args.Repair,
|
||||
CommandMode: moduletest.CleanupMode,
|
||||
}
|
||||
|
||||
var testDiags tfdiags.Diagnostics
|
||||
|
||||
go func() {
|
||||
defer logging.PanicHandler()
|
||||
defer done()
|
||||
defer stop()
|
||||
defer cancel()
|
||||
|
||||
_, testDiags = runner.Test(c.Meta.AllowExperimentalFeatures)
|
||||
}()
|
||||
|
||||
// Wait for the operation to complete, or for an interrupt to occur.
|
||||
select {
|
||||
case <-c.ShutdownCh:
|
||||
// Nice request to be cancelled.
|
||||
|
||||
view.Interrupted()
|
||||
runner.Stop()
|
||||
stop()
|
||||
|
||||
select {
|
||||
case <-c.ShutdownCh:
|
||||
// The user pressed it again, now we have to get it to stop as
|
||||
// fast as possible.
|
||||
|
||||
view.FatalInterrupt()
|
||||
runner.Cancel()
|
||||
cancel()
|
||||
|
||||
waitTime := 5 * time.Second
|
||||
|
||||
// We'll wait 5 seconds for this operation to finish now, regardless
|
||||
// of whether it finishes successfully or not.
|
||||
select {
|
||||
case <-runningCtx.Done():
|
||||
case <-time.After(waitTime):
|
||||
}
|
||||
|
||||
case <-runningCtx.Done():
|
||||
// The application finished nicely after the request was stopped.
|
||||
}
|
||||
case <-runningCtx.Done():
|
||||
// tests finished normally with no interrupts.
|
||||
}
|
||||
|
||||
view.Diagnostics(nil, nil, testDiags)
|
||||
|
||||
return 0
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,4 @@
|
||||
resource "test_resource" "a" {
|
||||
id = "12345"
|
||||
value = "foobar"
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
run "test" {
|
||||
backend "local" {}
|
||||
skip_cleanup = false
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
resource "test_resource" "a" {
|
||||
id = "12345"
|
||||
value = "foobar"
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
run "test" {
|
||||
backend "local" {}
|
||||
skip_cleanup = true
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
variable "id" {
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "destroy_fail" {
|
||||
type = bool
|
||||
default = false
|
||||
}
|
||||
|
||||
resource "test_resource" "resource" {
|
||||
value = var.id
|
||||
destroy_fail = var.destroy_fail
|
||||
}
|
||||
|
||||
output "id" {
|
||||
value = test_resource.resource.id
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
run "test" {
|
||||
variables {
|
||||
id = "test"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_two" {
|
||||
skip_cleanup = true # This will leave behind the state
|
||||
variables {
|
||||
id = "test_two"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_three" {
|
||||
state_key = "state_three"
|
||||
variables {
|
||||
id = "test_three"
|
||||
destroy_fail = true // This will fail to destroy and leave behind the state
|
||||
}
|
||||
}
|
||||
|
||||
run "test_four" {
|
||||
variables {
|
||||
id = "test_four"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
|
||||
variable "input" {
|
||||
type = string
|
||||
}
|
||||
|
||||
resource "test_resource" "a" {
|
||||
value = var.input
|
||||
}
|
||||
|
||||
resource "test_resource" "c" {}
|
||||
@ -0,0 +1,9 @@
|
||||
# The "foobar" backend does not exist and isn't a removed backend either
|
||||
run "test_invalid_backend" {
|
||||
variables {
|
||||
input = "foobar"
|
||||
}
|
||||
|
||||
backend "foobar" {
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
|
||||
variable "input" {
|
||||
type = string
|
||||
}
|
||||
|
||||
resource "test_resource" "a" {
|
||||
value = var.input
|
||||
}
|
||||
|
||||
resource "test_resource" "c" {}
|
||||
@ -0,0 +1,9 @@
|
||||
# The "etcd" backend has been removed from Terraform versions 1.3+
|
||||
run "test_removed_backend" {
|
||||
variables {
|
||||
input = "foobar"
|
||||
}
|
||||
|
||||
backend "etcd" {
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
|
||||
variable "input" {
|
||||
type = string
|
||||
}
|
||||
|
||||
resource "test_resource" "a" {
|
||||
value = var.input
|
||||
}
|
||||
|
||||
resource "test_resource" "c" {}
|
||||
@ -0,0 +1,9 @@
|
||||
|
||||
variable "input" {
|
||||
type = string
|
||||
}
|
||||
|
||||
module "foobar" {
|
||||
source = "./child-module"
|
||||
input = "foobar"
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
# The "state/terraform.tfstate" local backend is used with the implicit internal state "./child-module"
|
||||
run "test_1" {
|
||||
module {
|
||||
source = "./child-module"
|
||||
}
|
||||
|
||||
variables {
|
||||
input = "foobar"
|
||||
}
|
||||
|
||||
backend "local" {
|
||||
path = "state/terraform.tfstate"
|
||||
}
|
||||
}
|
||||
|
||||
# The "state/terraform.tfstate" local backend is used with the implicit internal state "" (empty string == root module under test)
|
||||
run "test_2" {
|
||||
|
||||
backend "local" {
|
||||
path = "state/terraform.tfstate"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
|
||||
variable "input" {
|
||||
type = string
|
||||
}
|
||||
|
||||
resource "test_resource" "a" {
|
||||
value = var.input
|
||||
}
|
||||
|
||||
resource "test_resource" "c" {}
|
||||
@ -0,0 +1,15 @@
|
||||
# The "state/terraform.tfstate" local backend is used with the user-supplied internal state "foobar-1"
|
||||
run "test_1" {
|
||||
state_key = "foobar-1"
|
||||
backend "local" {
|
||||
path = "state/terraform.tfstate"
|
||||
}
|
||||
}
|
||||
|
||||
# The "state/terraform.tfstate" local backend is used with the user-supplied internal state "foobar-2"
|
||||
run "test_2" {
|
||||
state_key = "foobar-2"
|
||||
backend "local" {
|
||||
path = "state/terraform.tfstate"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
variable "id" {
|
||||
type = string
|
||||
}
|
||||
|
||||
resource "test_resource" "resource" {
|
||||
value = var.id
|
||||
}
|
||||
|
||||
output "id" {
|
||||
value = test_resource.resource.id
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
run "test" {
|
||||
variables {
|
||||
id = "test"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_two" {
|
||||
skip_cleanup = true
|
||||
variables {
|
||||
id = "test_two"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_three" {
|
||||
skip_cleanup = true
|
||||
variables {
|
||||
id = "test_three"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_four" {
|
||||
variables {
|
||||
id = "test_four"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_five" {
|
||||
variables {
|
||||
id = "test_five"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
variable "id" {
|
||||
type = string
|
||||
}
|
||||
|
||||
resource "test_resource" "resource" {
|
||||
value = var.id
|
||||
}
|
||||
|
||||
output "id" {
|
||||
value = test_resource.resource.id
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
run "test" {
|
||||
skip_cleanup = true
|
||||
|
||||
variables {
|
||||
id = "foo"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
variable "id" {
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "unused" {
|
||||
type = string
|
||||
default = "unused"
|
||||
}
|
||||
|
||||
resource "test_resource" "resource" {
|
||||
value = var.id
|
||||
}
|
||||
|
||||
output "id" {
|
||||
value = test_resource.resource.id
|
||||
}
|
||||
|
||||
output "unused" {
|
||||
value = var.unused
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
run "test" {
|
||||
variables {
|
||||
id = "test"
|
||||
unused = "unused"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_two" {
|
||||
state_key = "state"
|
||||
skip_cleanup = true
|
||||
variables {
|
||||
id = "test_two"
|
||||
// The output state data for this dependency will also be left behind, but the actual
|
||||
// resource will have been destroyed by the cleanup step of test_three.
|
||||
unused = run.test.unused
|
||||
}
|
||||
}
|
||||
|
||||
run "test_three" {
|
||||
variables {
|
||||
id = "test_three"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
variable "id" {
|
||||
type = string
|
||||
}
|
||||
|
||||
resource "test_resource" "resource" {
|
||||
value = var.id
|
||||
}
|
||||
|
||||
output "id" {
|
||||
value = test_resource.resource.id
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
test {
|
||||
skip_cleanup = true
|
||||
}
|
||||
|
||||
run "test" {
|
||||
variables {
|
||||
id = "test"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_two" {
|
||||
variables {
|
||||
id = "test_two"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_three" {
|
||||
variables {
|
||||
id = "test_three"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_four" {
|
||||
variables {
|
||||
id = "test_four"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_five" {
|
||||
skip_cleanup = false # This will be cleaned up, and test_four will not
|
||||
variables {
|
||||
id = "test_five"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
variable "input" {
|
||||
type = string
|
||||
}
|
||||
|
||||
resource "test_resource" "foobar" {
|
||||
id = "12345"
|
||||
# Set deterministic ID because this fixture is for testing what happens when there's no prior state
|
||||
# i.e. this id will otherwise keep changing per test
|
||||
value = var.input
|
||||
}
|
||||
|
||||
output "test_resource_id" {
|
||||
value = test_resource.foobar.id
|
||||
}
|
||||
|
||||
output "supplied_input_value" {
|
||||
value = var.input
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
run "setup_pet_name" {
|
||||
backend "local" {
|
||||
// Use default path
|
||||
}
|
||||
|
||||
variables {
|
||||
input = "value-from-run-that-controls-backend"
|
||||
}
|
||||
}
|
||||
|
||||
run "edit_input" {
|
||||
variables {
|
||||
input = "this-value-should-not-enter-state"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
variable "input" {
|
||||
type = string
|
||||
}
|
||||
|
||||
resource "test_resource" "foobar" {
|
||||
# No ID set here
|
||||
# We should be able to assert about its value as it will be loaded from state
|
||||
# by the backend block in the run block
|
||||
value = var.input
|
||||
}
|
||||
|
||||
output "test_resource_id" {
|
||||
value = test_resource.foobar.id
|
||||
}
|
||||
|
||||
output "supplied_input_value" {
|
||||
value = var.input
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
run "setup_pet_name" {
|
||||
backend "local" {
|
||||
// Use default path
|
||||
}
|
||||
|
||||
variables {
|
||||
input = "value-from-run-that-controls-backend"
|
||||
}
|
||||
}
|
||||
|
||||
run "edit_input" {
|
||||
variables {
|
||||
input = "this-value-should-not-enter-state"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,41 @@
|
||||
{
|
||||
"version": 4,
|
||||
"terraform_version": "1.13.0",
|
||||
"serial": 1,
|
||||
"lineage": "c1f962ec-7cf6-281e-1eb8-eed10c450e16",
|
||||
"outputs": {
|
||||
"input": {
|
||||
"value": "value-from-run-that-controls-backend",
|
||||
"type": "string"
|
||||
},
|
||||
"test_resource_id": {
|
||||
"value": "53d69028-477d-7ba0-83c3-ff3807e3756f",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"mode": "managed",
|
||||
"type": "test_resource",
|
||||
"name": "foobar",
|
||||
"provider": "provider[\"registry.terraform.io/hashicorp/test\"]",
|
||||
"instances": [
|
||||
{
|
||||
"schema_version": 0,
|
||||
"attributes": {
|
||||
"create_wait_seconds": null,
|
||||
"destroy_fail": false,
|
||||
"destroy_wait_seconds": null,
|
||||
"id": "53d69028-477d-7ba0-83c3-ff3807e3756f",
|
||||
"interrupt_count": null,
|
||||
"value": null,
|
||||
"write_only": null
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"check_results": null
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
# This backend block is used in a plan run block
|
||||
# They're expected to be used in the first apply run block
|
||||
# for a given state key
|
||||
run "setup" {
|
||||
command = plan
|
||||
backend "local" {
|
||||
path = "/tests/other-state"
|
||||
}
|
||||
}
|
||||
|
||||
run "test" {
|
||||
command = apply
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
run "test_1" {
|
||||
command = apply
|
||||
}
|
||||
|
||||
# This run block uses the same internal state as test_1,
|
||||
# so this the backend block is attempting to load in state
|
||||
# when there is already non-empty internal state.
|
||||
run "test_2" {
|
||||
command = apply
|
||||
backend "local" {
|
||||
path = "/tests/other-state"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
# There cannot be two backend blocks in a single run block
|
||||
run "setup" {
|
||||
backend "local" {
|
||||
path = "/tests/state/terraform.tfstate"
|
||||
}
|
||||
backend "local" {
|
||||
path = "/tests/other-state/terraform.tfstate"
|
||||
}
|
||||
}
|
||||
|
||||
run "test" {
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
run "setup" {
|
||||
command = apply
|
||||
|
||||
backend "local" {
|
||||
path = "/tests/state/terraform.tfstate"
|
||||
}
|
||||
}
|
||||
|
||||
# "test" uses the same internal state file as "setup", which has already loaded state from a backend block
|
||||
# and is an apply run block.
|
||||
# The backend block can only occur once in a given set of run blocks that share state.
|
||||
run "test" {
|
||||
command = apply
|
||||
|
||||
backend "local" {
|
||||
path = "/tests/state/terraform.tfstate"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
run "test" {
|
||||
command = apply
|
||||
|
||||
backend "remote" {
|
||||
organization = "example_corp"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
run "backend" {
|
||||
command = apply
|
||||
|
||||
backend "local" {
|
||||
path = "/tests/state/terraform.tfstate"
|
||||
}
|
||||
}
|
||||
|
||||
run "skip_cleanup" {
|
||||
command = apply
|
||||
|
||||
# Should warn us about the skip_cleanup option being set.
|
||||
skip_cleanup = true
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
|
||||
variable "input" {
|
||||
type = string
|
||||
}
|
||||
|
||||
|
||||
resource "foo_resource" "a" {
|
||||
value = var.input
|
||||
}
|
||||
|
||||
resource "bar_resource" "c" {}
|
||||
@ -0,0 +1,22 @@
|
||||
variables {
|
||||
input = "default"
|
||||
}
|
||||
|
||||
# The backend in "load_state" is used to set an internal state without an explicit key
|
||||
run "load_state" {
|
||||
backend "local" {
|
||||
path = "state/terraform.tfstate"
|
||||
}
|
||||
}
|
||||
|
||||
# "test_run" uses the same internal state as "load_state"
|
||||
run "test_run" {
|
||||
variables {
|
||||
input = "custom"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = foo_resource.a.value == "custom"
|
||||
error_message = "invalid value"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
# The foobar-1 local backend is used with the user-supplied internal state "foobar-1"
|
||||
run "test_1" {
|
||||
state_key = "foobar-1"
|
||||
backend "local" {
|
||||
path = "state/foobar-1.tfstate"
|
||||
}
|
||||
}
|
||||
|
||||
# The foobar-2 local backend is used with the user-supplied internal state "foobar-2"
|
||||
run "test_2" {
|
||||
state_key = "foobar-2"
|
||||
backend "local" {
|
||||
path = "state/foobar-2.tfstate"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
resource "aws_instance" "web" {
|
||||
ami = "ami-1234"
|
||||
security_groups = [
|
||||
"foo",
|
||||
"bar",
|
||||
]
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
# These run blocks either:
|
||||
# 1) don't set an explicit state_key value and test the working directory,
|
||||
# so would have the same internal state file as run blocks in the other test file.
|
||||
# 2) do set an explicit state_key, which matches run blocks in the other test file.
|
||||
#
|
||||
# test_file_two.tftest.hcl as the same content as test_file_one.tftest.hcl,
|
||||
# with renamed run blocks.
|
||||
run "file_1_load_state" {
|
||||
backend "local" {
|
||||
path = "state/terraform.tfstate"
|
||||
}
|
||||
}
|
||||
|
||||
run "file_1_test" {
|
||||
assert {
|
||||
condition = aws_instance.web.ami == "ami-1234"
|
||||
error_message = "AMI should be ami-1234"
|
||||
}
|
||||
}
|
||||
|
||||
run "file_1_load_state_state_key" {
|
||||
state_key = "foobar"
|
||||
backend "local" {
|
||||
path = "state/terraform.tfstate"
|
||||
}
|
||||
}
|
||||
|
||||
run "file_1_test_state_key" {
|
||||
state_key = "foobar"
|
||||
assert {
|
||||
condition = aws_instance.web.ami == "ami-1234"
|
||||
error_message = "AMI should be ami-1234"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
# These run blocks either:
|
||||
# 1) don't set an explicit state_key value and test the working directory,
|
||||
# so would have the same internal state file as run blocks in the other test file.
|
||||
# 2) do set an explicit state_key, which matches run blocks in the other test file.
|
||||
#
|
||||
# test_file_two.tftest.hcl as the same content as test_file_one.tftest.hcl,
|
||||
# with renamed run blocks.
|
||||
run "file_2_load_state" {
|
||||
backend "local" {
|
||||
path = "state/terraform.tfstate"
|
||||
}
|
||||
}
|
||||
|
||||
run "file_2_test" {
|
||||
assert {
|
||||
condition = aws_instance.web.ami == "ami-1234"
|
||||
error_message = "AMI should be ami-1234"
|
||||
}
|
||||
}
|
||||
|
||||
run "file_2_load_state_state_key" {
|
||||
state_key = "foobar"
|
||||
backend "local" {
|
||||
path = "state/terraform.tfstate"
|
||||
}
|
||||
}
|
||||
|
||||
run "file_2_test_state_key" {
|
||||
state_key = "foobar"
|
||||
assert {
|
||||
condition = aws_instance.web.ami == "ami-1234"
|
||||
error_message = "AMI should be ami-1234"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,105 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package graph
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/hashicorp/hcl/v2"
|
||||
"github.com/zclconf/go-cty/cty"
|
||||
|
||||
"github.com/hashicorp/terraform/internal/addrs"
|
||||
"github.com/hashicorp/terraform/internal/lang/marks"
|
||||
"github.com/hashicorp/terraform/internal/moduletest"
|
||||
teststates "github.com/hashicorp/terraform/internal/moduletest/states"
|
||||
"github.com/hashicorp/terraform/internal/tfdiags"
|
||||
)
|
||||
|
||||
var (
|
||||
_ GraphNodeExecutable = (*NodeTestRunCleanup)(nil)
|
||||
_ GraphNodeReferenceable = (*NodeTestRunCleanup)(nil)
|
||||
_ GraphNodeReferencer = (*NodeTestRunCleanup)(nil)
|
||||
)
|
||||
|
||||
type NodeTestRunCleanup struct {
|
||||
run *moduletest.Run
|
||||
priorRuns map[string]*moduletest.Run
|
||||
opts *graphOptions
|
||||
}
|
||||
|
||||
func (n *NodeTestRunCleanup) Name() string {
|
||||
return fmt.Sprintf("%s.%s (cleanup)", n.opts.File.Name, n.run.Addr().String())
|
||||
}
|
||||
|
||||
func (n *NodeTestRunCleanup) References() []*addrs.Reference {
|
||||
references, _ := moduletest.GetRunReferences(n.run.Config)
|
||||
|
||||
for _, run := range n.priorRuns {
|
||||
// we'll also draw an implicit reference to all prior runs to make sure
|
||||
// they execute first
|
||||
references = append(references, &addrs.Reference{
|
||||
Subject: run.Addr(),
|
||||
SourceRange: tfdiags.SourceRangeFromHCL(n.run.Config.DeclRange),
|
||||
})
|
||||
}
|
||||
|
||||
for name, variable := range n.run.ModuleConfig.Module.Variables {
|
||||
|
||||
// because we also draw implicit references back to any variables
|
||||
// defined in the test file with the same name as actual variables, then
|
||||
// we'll count these as references as well.
|
||||
|
||||
if _, ok := n.run.Config.Variables[name]; ok {
|
||||
|
||||
// BUT, if the variable is defined within the list of variables
|
||||
// within the run block then we don't want to draw an implicit
|
||||
// reference as the data comes from that expression.
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
references = append(references, &addrs.Reference{
|
||||
Subject: addrs.InputVariable{Name: name},
|
||||
SourceRange: tfdiags.SourceRangeFromHCL(variable.DeclRange),
|
||||
})
|
||||
}
|
||||
|
||||
return references
|
||||
}
|
||||
|
||||
func (n *NodeTestRunCleanup) Referenceable() addrs.Referenceable {
|
||||
return n.run.Addr()
|
||||
}
|
||||
|
||||
func (n *NodeTestRunCleanup) Execute(ctx *EvalContext) {
|
||||
log.Printf("[TRACE] TestFileRunner: executing run block %s/%s", n.opts.File.Name, n.run.Name)
|
||||
|
||||
n.run.Status = moduletest.Pass
|
||||
|
||||
state, err := ctx.LoadState(n.run.Config)
|
||||
if err != nil {
|
||||
n.run.Status = moduletest.Fail
|
||||
n.run.Diagnostics = n.run.Diagnostics.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Failed to load state",
|
||||
Detail: fmt.Sprintf("Could not retrieve state for run %s: %s.", n.run.Name, err),
|
||||
Subject: n.run.Config.Backend.DeclRange.Ptr(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
outputs := make(map[string]cty.Value)
|
||||
for name, output := range state.RootOutputValues {
|
||||
if output.Sensitive {
|
||||
outputs[name] = output.Value.Mark(marks.Sensitive)
|
||||
continue
|
||||
}
|
||||
outputs[name] = output.Value
|
||||
}
|
||||
n.run.Outputs = cty.ObjectVal(outputs)
|
||||
|
||||
ctx.SetFileState(n.run.Config.StateKey, n.run, state, teststates.StateReasonNone)
|
||||
ctx.AddRunBlock(n.run)
|
||||
}
|
||||
@ -1,47 +0,0 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package graph
|
||||
|
||||
import (
|
||||
"github.com/hashicorp/terraform/internal/dag"
|
||||
"github.com/hashicorp/terraform/internal/moduletest"
|
||||
"github.com/hashicorp/terraform/internal/states"
|
||||
"github.com/hashicorp/terraform/internal/terraform"
|
||||
)
|
||||
|
||||
var _ terraform.GraphTransformer = (*EvalContextTransformer)(nil)
|
||||
|
||||
// EvalContextTransformer should be the first node to execute in the graph, and
|
||||
// it initialises the run blocks and state files in the evaluation context.
|
||||
type EvalContextTransformer struct {
|
||||
File *moduletest.File
|
||||
}
|
||||
|
||||
func (e *EvalContextTransformer) Transform(graph *terraform.Graph) error {
|
||||
node := &dynamicNode{
|
||||
eval: func(ctx *EvalContext) {
|
||||
for _, run := range e.File.Runs {
|
||||
// initialise all the state keys before the graph starts
|
||||
// properly
|
||||
key := run.Config.StateKey
|
||||
if state := ctx.GetFileState(key); state == nil {
|
||||
ctx.SetFileState(key, &TestFileState{
|
||||
Run: nil,
|
||||
State: states.NewState(),
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
graph.Add(node)
|
||||
for v := range graph.VerticesSeq() {
|
||||
if v == node {
|
||||
continue
|
||||
}
|
||||
graph.Connect(dag.BasicEdge(v, node))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@ -0,0 +1,85 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package moduletest
|
||||
|
||||
import (
|
||||
"github.com/hashicorp/hcl/v2"
|
||||
|
||||
"github.com/hashicorp/terraform/internal/addrs"
|
||||
"github.com/hashicorp/terraform/internal/configs"
|
||||
"github.com/hashicorp/terraform/internal/lang/langrefs"
|
||||
"github.com/hashicorp/terraform/internal/tfdiags"
|
||||
)
|
||||
|
||||
func GetRunTargets(config *configs.TestRun) ([]addrs.Targetable, tfdiags.Diagnostics) {
|
||||
var diagnostics tfdiags.Diagnostics
|
||||
var targets []addrs.Targetable
|
||||
|
||||
for _, target := range config.Options.Target {
|
||||
addr, diags := addrs.ParseTarget(target)
|
||||
diagnostics = diagnostics.Append(diags)
|
||||
if addr != nil {
|
||||
targets = append(targets, addr.Subject)
|
||||
}
|
||||
}
|
||||
|
||||
return targets, diagnostics
|
||||
}
|
||||
|
||||
func GetRunReplaces(config *configs.TestRun) ([]addrs.AbsResourceInstance, tfdiags.Diagnostics) {
|
||||
var diagnostics tfdiags.Diagnostics
|
||||
var replaces []addrs.AbsResourceInstance
|
||||
|
||||
for _, replace := range config.Options.Replace {
|
||||
addr, diags := addrs.ParseAbsResourceInstance(replace)
|
||||
diagnostics = diagnostics.Append(diags)
|
||||
if diags.HasErrors() {
|
||||
continue
|
||||
}
|
||||
|
||||
if addr.Resource.Resource.Mode != addrs.ManagedResourceMode {
|
||||
diagnostics = diagnostics.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Can only target managed resources for forced replacements.",
|
||||
Detail: addr.String(),
|
||||
Subject: replace.SourceRange().Ptr(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
replaces = append(replaces, addr)
|
||||
}
|
||||
|
||||
return replaces, diagnostics
|
||||
}
|
||||
|
||||
func GetRunReferences(config *configs.TestRun) ([]*addrs.Reference, tfdiags.Diagnostics) {
|
||||
var diagnostics tfdiags.Diagnostics
|
||||
var references []*addrs.Reference
|
||||
|
||||
for _, rule := range config.CheckRules {
|
||||
for _, variable := range rule.Condition.Variables() {
|
||||
reference, diags := addrs.ParseRefFromTestingScope(variable)
|
||||
diagnostics = diagnostics.Append(diags)
|
||||
if reference != nil {
|
||||
references = append(references, reference)
|
||||
}
|
||||
}
|
||||
for _, variable := range rule.ErrorMessage.Variables() {
|
||||
reference, diags := addrs.ParseRefFromTestingScope(variable)
|
||||
diagnostics = diagnostics.Append(diags)
|
||||
if reference != nil {
|
||||
references = append(references, reference)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, expr := range config.Variables {
|
||||
moreRefs, moreDiags := langrefs.ReferencesInExpr(addrs.ParseRefFromTestingScope, expr)
|
||||
diagnostics = diagnostics.Append(moreDiags)
|
||||
references = append(references, moreRefs...)
|
||||
}
|
||||
|
||||
return references, diagnostics
|
||||
}
|
||||
@ -0,0 +1,558 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package states
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math/rand/v2"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/hashicorp/hcl/v2"
|
||||
"github.com/hashicorp/hcl/v2/hcldec"
|
||||
|
||||
"github.com/hashicorp/terraform/internal/backend"
|
||||
"github.com/hashicorp/terraform/internal/command/workdir"
|
||||
"github.com/hashicorp/terraform/internal/configs"
|
||||
"github.com/hashicorp/terraform/internal/moduletest"
|
||||
"github.com/hashicorp/terraform/internal/states"
|
||||
"github.com/hashicorp/terraform/internal/states/statemgr"
|
||||
"github.com/hashicorp/terraform/internal/tfdiags"
|
||||
)
|
||||
|
||||
const alphanumeric = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
type StateReason string
|
||||
|
||||
const (
|
||||
StateReasonNone StateReason = ""
|
||||
StateReasonSkip StateReason = "skip_cleanup"
|
||||
StateReasonDep StateReason = "dependency"
|
||||
StateReasonError StateReason = "error"
|
||||
)
|
||||
|
||||
// TestManifest represents the structure of the manifest file that keeps track
|
||||
// of the state files left-over during test runs.
|
||||
type TestManifest struct {
|
||||
Version int `json:"version"`
|
||||
Files map[string]*TestFileManifest `json:"files"`
|
||||
|
||||
dataDir string // Directory where all test-related data is stored
|
||||
ids map[string]bool
|
||||
}
|
||||
|
||||
// TestFileManifest represents a single file with its states keyed by the state
|
||||
// key.
|
||||
type TestFileManifest struct {
|
||||
States map[string]*TestRunManifest `json:"states"` // Map of state keys to their manifests.
|
||||
}
|
||||
|
||||
// TestRunManifest represents an individual test run state.
|
||||
type TestRunManifest struct {
|
||||
// ID of the state file, used for identification. This will be empty if the
|
||||
// state was written to a real backend and not stored locally.
|
||||
ID string `json:"id,omitempty"`
|
||||
|
||||
// Reason for the state being left over
|
||||
Reason StateReason `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// LoadManifest loads the test manifest from the specified root directory.
|
||||
func LoadManifest(rootDir string, experimentsAllowed bool) (*TestManifest, error) {
|
||||
if !experimentsAllowed {
|
||||
// Just return an empty manifest file every time when experiments are
|
||||
// disabled.
|
||||
return &TestManifest{
|
||||
Version: 0,
|
||||
Files: make(map[string]*TestFileManifest),
|
||||
dataDir: workdir.NewDir(rootDir).TestDataDir(),
|
||||
ids: make(map[string]bool),
|
||||
}, nil
|
||||
}
|
||||
|
||||
wd := workdir.NewDir(rootDir)
|
||||
|
||||
manifest := &TestManifest{
|
||||
Version: 0,
|
||||
Files: make(map[string]*TestFileManifest),
|
||||
dataDir: wd.TestDataDir(),
|
||||
ids: make(map[string]bool),
|
||||
}
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
if err := manifest.ensureDataDir(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, err := os.OpenFile(manifest.filePath(), os.O_CREATE|os.O_RDONLY, 0644)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer data.Close()
|
||||
|
||||
if err := json.NewDecoder(data).Decode(manifest); err != nil && err != io.EOF {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, fileManifest := range manifest.Files {
|
||||
for _, runManifest := range fileManifest.States {
|
||||
// keep a cache of all known ids
|
||||
manifest.ids[runManifest.ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
// Save saves the current state of the manifest to the data directory.
|
||||
func (manifest *TestManifest) Save(experimentsAllowed bool) error {
|
||||
if !experimentsAllowed {
|
||||
// just don't save the manifest file when experiments are disabled.
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(manifest, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(manifest.filePath(), data, 0644)
|
||||
}
|
||||
|
||||
// LoadStates loads the states for the specified file.
|
||||
func (manifest *TestManifest) LoadStates(file *moduletest.File, factory func(string) backend.InitFn) (map[string]*TestRunState, tfdiags.Diagnostics) {
|
||||
var diags tfdiags.Diagnostics
|
||||
|
||||
allStates := make(map[string]*TestRunState)
|
||||
|
||||
var existingStates map[string]*TestRunManifest
|
||||
if fm, exists := manifest.Files[file.Name]; exists {
|
||||
existingStates = fm.States
|
||||
}
|
||||
|
||||
for _, run := range file.Runs {
|
||||
key := run.Config.StateKey
|
||||
if existing, exists := allStates[key]; exists {
|
||||
|
||||
if run.Config.Backend != nil {
|
||||
f := factory(run.Config.Backend.Type)
|
||||
if f == nil {
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Unknown backend type",
|
||||
Detail: fmt.Sprintf("Backend type %q is not a recognised backend.", run.Config.Backend.Type),
|
||||
Subject: run.Config.Backend.DeclRange.Ptr(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
be, err := getBackendInstance(run.Config.StateKey, run.Config.Backend, f)
|
||||
if err != nil {
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Invalid backend configuration",
|
||||
Detail: fmt.Sprintf("Backend configuration was invalid: %s.", err),
|
||||
Subject: run.Config.Backend.DeclRange.Ptr(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Save the backend for this state when we find it, even if the
|
||||
// state was initialised first.
|
||||
existing.Backend = be
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
var backend backend.Backend
|
||||
if run.Config.Backend != nil {
|
||||
// Then we have to load the state from the backend instead of
|
||||
// locally or creating a new one.
|
||||
|
||||
f := factory(run.Config.Backend.Type)
|
||||
if f == nil {
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Unknown backend type",
|
||||
Detail: fmt.Sprintf("Backend type %q is not a recognised backend.", run.Config.Backend.Type),
|
||||
Subject: run.Config.Backend.DeclRange.Ptr(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
be, err := getBackendInstance(run.Config.StateKey, run.Config.Backend, f)
|
||||
if err != nil {
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Invalid backend configuration",
|
||||
Detail: fmt.Sprintf("Backend configuration was invalid: %s.", err),
|
||||
Subject: run.Config.Backend.DeclRange.Ptr(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
backend = be
|
||||
}
|
||||
|
||||
if existing := existingStates[key]; existing != nil {
|
||||
|
||||
var state *states.State
|
||||
if len(existing.ID) > 0 {
|
||||
s, err := manifest.loadState(existing)
|
||||
if err != nil {
|
||||
diags = diags.Append(tfdiags.Sourceless(
|
||||
tfdiags.Error,
|
||||
"Failed to load state",
|
||||
fmt.Sprintf("Failed to load state from manifest file for %s: %s", run.Name, err)))
|
||||
continue
|
||||
}
|
||||
state = s
|
||||
} else {
|
||||
state = states.NewState()
|
||||
}
|
||||
|
||||
allStates[key] = &TestRunState{
|
||||
Run: run,
|
||||
Manifest: &TestRunManifest{ // copy this, so we can edit without affecting the original
|
||||
ID: existing.ID,
|
||||
Reason: existing.Reason,
|
||||
},
|
||||
State: state,
|
||||
Backend: backend,
|
||||
}
|
||||
} else {
|
||||
var id string
|
||||
if backend == nil {
|
||||
id = manifest.generateID()
|
||||
}
|
||||
|
||||
allStates[key] = &TestRunState{
|
||||
Run: run,
|
||||
Manifest: &TestRunManifest{
|
||||
ID: id,
|
||||
Reason: StateReasonNone,
|
||||
},
|
||||
State: states.NewState(),
|
||||
Backend: backend,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for key := range existingStates {
|
||||
if _, exists := allStates[key]; !exists {
|
||||
stateKey := key
|
||||
if stateKey == configs.TestMainStateIdentifier {
|
||||
stateKey = "for the module under test"
|
||||
}
|
||||
|
||||
diags = diags.Append(tfdiags.Sourceless(
|
||||
tfdiags.Warning,
|
||||
"Orphaned state",
|
||||
fmt.Sprintf("The state key %s is stored in the state manifest indicating a failed cleanup operation, but the state key is not claimed by any run blocks within the current test file. Either restore a run block that manages the specified state, or manually cleanup this state file.", stateKey)))
|
||||
}
|
||||
}
|
||||
|
||||
return allStates, diags
|
||||
}
|
||||
|
||||
func (manifest *TestManifest) loadState(state *TestRunManifest) (*states.State, error) {
|
||||
stateFile := statemgr.NewFilesystem(manifest.StateFilePath(state.ID))
|
||||
if err := stateFile.RefreshState(); err != nil {
|
||||
return nil, fmt.Errorf("error loading state from file %s: %w", manifest.StateFilePath(state.ID), err)
|
||||
}
|
||||
return stateFile.State(), nil
|
||||
}
|
||||
|
||||
// SaveStates saves the states for the specified file to the manifest.
|
||||
func (manifest *TestManifest) SaveStates(file *moduletest.File, states map[string]*TestRunState) tfdiags.Diagnostics {
|
||||
var diags tfdiags.Diagnostics
|
||||
|
||||
if existingStates, exists := manifest.Files[file.Name]; exists {
|
||||
|
||||
// If we have existing states, we're doing update or delete operations
|
||||
// rather than just adding new states.
|
||||
|
||||
for key, existingState := range existingStates.States {
|
||||
|
||||
// First, check all the existing states against the states being
|
||||
// saved.
|
||||
|
||||
if state, exists := states[key]; exists {
|
||||
|
||||
// If we have a new state, then overwrite the existing one
|
||||
// assuming that it has a reason to be saved.
|
||||
|
||||
if state.Backend != nil {
|
||||
// If we have a backend, regardless of the reason, then
|
||||
// we'll save the state to the backend.
|
||||
|
||||
stmgr, err := state.Backend.StateMgr(backend.DefaultStateName)
|
||||
if err != nil {
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Failed to write state",
|
||||
Detail: fmt.Sprintf("Failed to write state file for key %s: %s.", key, err),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if err := stmgr.WriteState(state.State); err != nil {
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Failed to write state",
|
||||
Detail: fmt.Sprintf("Failed to write state file for key %s: %s.", key, err),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// But, still keep the manifest file itself up-to-date.
|
||||
|
||||
if state.Manifest.Reason != StateReasonNone {
|
||||
existingStates.States[key] = state.Manifest
|
||||
} else {
|
||||
delete(existingStates.States, key)
|
||||
}
|
||||
|
||||
} else if state.Manifest.Reason != StateReasonNone {
|
||||
if err := manifest.writeState(state); err != nil {
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Failed to write state",
|
||||
Detail: fmt.Sprintf("Failed to write state file for key %s: %s.", key, err),
|
||||
})
|
||||
continue
|
||||
}
|
||||
existingStates.States[key] = state.Manifest
|
||||
continue
|
||||
} else {
|
||||
|
||||
// If no reason to be saved, then it means we managed to
|
||||
// clean everything up properly. So we'll delete the
|
||||
// existing state file and remove any mention of it.
|
||||
|
||||
if err := manifest.deleteState(existingState); err != nil {
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Failed to delete state",
|
||||
Detail: fmt.Sprintf("Failed to delete state file for key %s: %s.", key, err),
|
||||
})
|
||||
continue
|
||||
}
|
||||
delete(existingStates.States, key) // remove the state from the manifest file
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, we just leave the state file as is. We don't want to
|
||||
// remove it prematurely, as users might still need it to tidy
|
||||
// something up.
|
||||
|
||||
}
|
||||
|
||||
// now, we've updated / removed any pre-existing states we should also
|
||||
// write any states that are brand new, and weren't in the existing
|
||||
// state.
|
||||
|
||||
for key, state := range states {
|
||||
if _, exists := existingStates.States[key]; exists {
|
||||
// we've already handled everything in the existing state
|
||||
continue
|
||||
}
|
||||
|
||||
if state.Backend != nil {
|
||||
|
||||
stmgr, err := state.Backend.StateMgr(backend.DefaultStateName)
|
||||
if err != nil {
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Failed to write state",
|
||||
Detail: fmt.Sprintf("Failed to write state file for key %s: %s.", key, err),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if err := stmgr.WriteState(state.State); err != nil {
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Failed to write state",
|
||||
Detail: fmt.Sprintf("Failed to write state file for key %s: %s.", key, err),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if state.Manifest.Reason != StateReasonNone {
|
||||
existingStates.States[key] = state.Manifest
|
||||
}
|
||||
} else if state.Manifest.Reason != StateReasonNone {
|
||||
if err := manifest.writeState(state); err != nil {
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Failed to write state",
|
||||
Detail: fmt.Sprintf("Failed to write state file for key %s: %s.", key, err),
|
||||
})
|
||||
continue
|
||||
}
|
||||
existingStates.States[key] = state.Manifest
|
||||
}
|
||||
}
|
||||
|
||||
if len(existingStates.States) == 0 {
|
||||
// if we now have tidied everything up, remove record of this from
|
||||
// the manifest.
|
||||
delete(manifest.Files, file.Name)
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
// We're just writing entirely new states, so we can just create a new
|
||||
// TestFileManifest and add it to the manifest.
|
||||
|
||||
newStates := make(map[string]*TestRunManifest)
|
||||
for key, state := range states {
|
||||
if state.Backend != nil {
|
||||
|
||||
stmgr, err := state.Backend.StateMgr(backend.DefaultStateName)
|
||||
if err != nil {
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Failed to write state",
|
||||
Detail: fmt.Sprintf("Failed to write state file for key %s: %s.", key, err),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if err := stmgr.WriteState(state.State); err != nil {
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Failed to write state",
|
||||
Detail: fmt.Sprintf("Failed to write state file for key %s: %s.", key, err),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if state.Manifest.Reason != StateReasonNone {
|
||||
newStates[key] = state.Manifest
|
||||
}
|
||||
} else if state.Manifest.Reason != StateReasonNone {
|
||||
if err := manifest.writeState(state); err != nil {
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Failed to write state",
|
||||
Detail: fmt.Sprintf("Failed to write state file for key %s: %s.", key, err),
|
||||
})
|
||||
continue
|
||||
}
|
||||
newStates[key] = state.Manifest
|
||||
}
|
||||
}
|
||||
|
||||
if len(newStates) > 0 {
|
||||
|
||||
// only add this into the manifest if we actually wrote any
|
||||
// new states
|
||||
|
||||
manifest.Files[file.Name] = &TestFileManifest{
|
||||
States: newStates,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return diags
|
||||
}
|
||||
|
||||
func (manifest *TestManifest) writeState(state *TestRunState) error {
|
||||
stateFile := statemgr.NewFilesystem(manifest.StateFilePath(state.Manifest.ID))
|
||||
if err := stateFile.WriteState(state.State); err != nil {
|
||||
return fmt.Errorf("error writing state to file %s: %w", manifest.StateFilePath(state.Manifest.ID), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manifest *TestManifest) deleteState(runManifest *TestRunManifest) error {
|
||||
target := manifest.StateFilePath(runManifest.ID)
|
||||
if err := os.Remove(target); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// If the file doesn't exist, we can ignore this error.
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("error deleting state file %s: %w", target, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manifest *TestManifest) generateID() string {
|
||||
const maxAttempts = 10
|
||||
|
||||
for ix := 0; ix < maxAttempts; ix++ {
|
||||
var b [8]byte
|
||||
for i := range b {
|
||||
n := rand.IntN(len(alphanumeric))
|
||||
b[i] = alphanumeric[n]
|
||||
}
|
||||
|
||||
id := string(b[:])
|
||||
if _, exists := manifest.ids[id]; exists {
|
||||
continue // generate another one
|
||||
}
|
||||
|
||||
manifest.ids[id] = true
|
||||
return id
|
||||
}
|
||||
|
||||
panic("failed to generate a unique id 10 times")
|
||||
}
|
||||
|
||||
func (manifest *TestManifest) ensureDataDir() error {
|
||||
if _, err := os.Stat(manifest.dataDir); os.IsNotExist(err) {
|
||||
return os.MkdirAll(manifest.dataDir, 0755)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// filePath returns the path to the manifest file
|
||||
func (manifest *TestManifest) filePath() string {
|
||||
return filepath.Join(manifest.dataDir, "manifest.json")
|
||||
}
|
||||
|
||||
// StateFilePath returns the path to the state file for a given ID.
|
||||
//
|
||||
// Visible for testing purposes.
|
||||
func (manifest *TestManifest) StateFilePath(id string) string {
|
||||
return filepath.Join(manifest.dataDir, fmt.Sprintf("%s.tfstate", id))
|
||||
}
|
||||
|
||||
// getBackendInstance uses the config for a given run block's backend block to create and return a configured
|
||||
// instance of that backend type.
|
||||
func getBackendInstance(stateKey string, config *configs.Backend, f backend.InitFn) (backend.Backend, error) {
|
||||
b := f()
|
||||
log.Printf("[TRACE] TestConfigTransformer.Transform: instantiated backend of type %T", b)
|
||||
|
||||
schema := b.ConfigSchema()
|
||||
decSpec := schema.NoneRequired().DecoderSpec()
|
||||
configVal, hclDiags := hcldec.Decode(config.Config, decSpec, nil)
|
||||
if hclDiags.HasErrors() {
|
||||
return nil, fmt.Errorf("error decoding backend configuration for state key %s : %v", stateKey, hclDiags.Errs())
|
||||
}
|
||||
|
||||
if !configVal.IsWhollyKnown() {
|
||||
return nil, fmt.Errorf("unknown values within backend definition for state key %s", stateKey)
|
||||
}
|
||||
|
||||
newVal, validateDiags := b.PrepareConfig(configVal)
|
||||
validateDiags = validateDiags.InConfigBody(config.Config, "")
|
||||
if validateDiags.HasErrors() {
|
||||
return nil, validateDiags.Err()
|
||||
}
|
||||
|
||||
configureDiags := b.Configure(newVal)
|
||||
configureDiags = configureDiags.InConfigBody(config.Config, "")
|
||||
if validateDiags.HasErrors() {
|
||||
return nil, configureDiags.Err()
|
||||
}
|
||||
|
||||
return b, nil
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package states
|
||||
|
||||
import (
|
||||
"github.com/hashicorp/terraform/internal/backend"
|
||||
"github.com/hashicorp/terraform/internal/moduletest"
|
||||
"github.com/hashicorp/terraform/internal/states"
|
||||
)
|
||||
|
||||
type TestRunState struct {
|
||||
// Run and RestoreState represent the run block to use to either destroy
|
||||
// or restore the state to. If RestoreState is false, then the state will
|
||||
// destroyed, if true it will be restored to the config of the relevant
|
||||
// run block.
|
||||
Run *moduletest.Run
|
||||
RestoreState bool
|
||||
|
||||
// Manifest is the underlying state manifest for this state.
|
||||
Manifest *TestRunManifest
|
||||
|
||||
// State is the actual state.
|
||||
State *states.State
|
||||
|
||||
// Backend is the backend where this state should be saved upon test
|
||||
// completion.
|
||||
Backend backend.Backend
|
||||
}
|
||||
Loading…
Reference in new issue