Skip to content

Commit 27045e4

Browse files
aknyshclaude
andauthored
fix: Terraform state path for disabled workspaces (#1929)
* fix terraform backends with workspace disabled * fix: Update test to place state file at correct location for default workspace The test was placing state files at terraform.tfstate.d/default/terraform.tfstate, but when workspace is "default", state should be at terraform.tfstate. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * address comments, add blog post * address comments, add tests --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1d39233 commit 27045e4

File tree

9 files changed

+672
-15
lines changed

9 files changed

+672
-15
lines changed
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
package exec
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
log "github.com/cloudposse/atmos/pkg/logger"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
12+
cfg "github.com/cloudposse/atmos/pkg/config"
13+
"github.com/cloudposse/atmos/pkg/schema"
14+
u "github.com/cloudposse/atmos/pkg/utils"
15+
)
16+
17+
// TestYamlFuncTerraformStateWorkspacesDisabled tests that the !terraform.state YAML function
18+
// works correctly when Terraform workspaces are disabled (workspaces_enabled: false).
19+
//
20+
// When workspaces are disabled:
21+
// - The workspace name is "default"
22+
// - For local backend: state is stored at terraform.tfstate (not terraform.tfstate.d/default/terraform.tfstate)
23+
// - For S3 backend: state is stored at <key> (not <workspace_key_prefix>/default/<key>)
24+
//
25+
// See: https://github.com/cloudposse/atmos/issues/1920
26+
func TestYamlFuncTerraformStateWorkspacesDisabled(t *testing.T) {
27+
err := os.Unsetenv("ATMOS_CLI_CONFIG_PATH")
28+
if err != nil {
29+
t.Fatalf("Failed to unset 'ATMOS_CLI_CONFIG_PATH': %v", err)
30+
}
31+
32+
err = os.Unsetenv("ATMOS_BASE_PATH")
33+
if err != nil {
34+
t.Fatalf("Failed to unset 'ATMOS_BASE_PATH': %v", err)
35+
}
36+
37+
log.SetLevel(log.InfoLevel)
38+
log.SetOutput(os.Stdout)
39+
40+
stack := "test"
41+
42+
defer func() {
43+
// Delete the generated files and folders after the test.
44+
mockComponentPath := filepath.Join("..", "..", "tests", "fixtures", "components", "terraform", "mock")
45+
// Clean up terraform state files.
46+
err := os.RemoveAll(filepath.Join(mockComponentPath, ".terraform"))
47+
assert.NoError(t, err)
48+
49+
err = os.RemoveAll(filepath.Join(mockComponentPath, "terraform.tfstate.d"))
50+
assert.NoError(t, err)
51+
52+
// When workspaces are disabled, state is stored at terraform.tfstate (not in terraform.tfstate.d/).
53+
err = os.Remove(filepath.Join(mockComponentPath, "terraform.tfstate"))
54+
// Ignore error if file doesn't exist.
55+
if err != nil && !os.IsNotExist(err) {
56+
assert.NoError(t, err)
57+
}
58+
59+
err = os.Remove(filepath.Join(mockComponentPath, "terraform.tfstate.backup"))
60+
// Ignore error if file doesn't exist.
61+
if err != nil && !os.IsNotExist(err) {
62+
assert.NoError(t, err)
63+
}
64+
}()
65+
66+
// Define the working directory (workspaces-disabled fixture).
67+
workDir := "../../tests/fixtures/scenarios/atmos-terraform-state-yaml-function-workspaces-disabled"
68+
t.Chdir(workDir)
69+
70+
// Deploy component-1 first to create terraform state.
71+
info := schema.ConfigAndStacksInfo{
72+
StackFromArg: "",
73+
Stack: stack,
74+
StackFile: "",
75+
ComponentType: "terraform",
76+
ComponentFromArg: "component-1",
77+
SubCommand: "deploy",
78+
ProcessTemplates: true,
79+
ProcessFunctions: true,
80+
}
81+
82+
err = ExecuteTerraform(info)
83+
require.NoError(t, err, "Failed to execute 'ExecuteTerraform' for component-1")
84+
85+
// Initialize CLI config.
86+
atmosConfig, err := cfg.InitCliConfig(info, true)
87+
require.NoError(t, err)
88+
89+
// Verify that workspaces are disabled.
90+
require.NotNil(t, atmosConfig.Components.Terraform.WorkspacesEnabled)
91+
assert.False(t, *atmosConfig.Components.Terraform.WorkspacesEnabled,
92+
"Expected workspaces to be disabled in this test fixture")
93+
94+
// Test !terraform.state can read outputs from component-1.
95+
// When workspaces are disabled, the state should be at terraform.tfstate (not terraform.tfstate.d/default/terraform.tfstate).
96+
d, err := processTagTerraformState(&atmosConfig, "!terraform.state component-1 foo", stack, nil)
97+
require.NoError(t, err)
98+
assert.Equal(t, "component-1-a", d, "Expected to read 'foo' output from component-1 state")
99+
100+
d, err = processTagTerraformState(&atmosConfig, "!terraform.state component-1 bar", stack, nil)
101+
require.NoError(t, err)
102+
assert.Equal(t, "component-1-b", d, "Expected to read 'bar' output from component-1 state")
103+
104+
d, err = processTagTerraformState(&atmosConfig, "!terraform.state component-1 test baz", "", nil)
105+
require.NoError(t, err)
106+
assert.Equal(t, "component-1-c", d, "Expected to read 'baz' output from component-1 state")
107+
108+
// Verify component-2 can use !terraform.state to reference component-1's outputs.
109+
res, err := ExecuteDescribeComponent(&ExecuteDescribeComponentParams{
110+
Component: "component-2",
111+
Stack: stack,
112+
ProcessTemplates: true,
113+
ProcessYamlFunctions: true,
114+
Skip: nil,
115+
AuthManager: nil,
116+
})
117+
require.NoError(t, err)
118+
119+
y, err := u.ConvertToYAML(res)
120+
require.NoError(t, err)
121+
assert.Contains(t, y, "foo: component-1-a", "component-2 should have foo from component-1 state")
122+
assert.Contains(t, y, "bar: component-1-b", "component-2 should have bar from component-1 state")
123+
assert.Contains(t, y, "baz: component-1-c", "component-2 should have baz from component-1 state")
124+
}
125+
126+
// TestWorkspacesDisabledStateLocation verifies that when workspaces are disabled,
127+
// the terraform state is stored at the correct location (terraform.tfstate, not terraform.tfstate.d/default/terraform.tfstate).
128+
func TestWorkspacesDisabledStateLocation(t *testing.T) {
129+
err := os.Unsetenv("ATMOS_CLI_CONFIG_PATH")
130+
require.NoError(t, err)
131+
132+
err = os.Unsetenv("ATMOS_BASE_PATH")
133+
require.NoError(t, err)
134+
135+
log.SetLevel(log.InfoLevel)
136+
log.SetOutput(os.Stdout)
137+
138+
stack := "test"
139+
140+
// Get the absolute path to the mock component before changing directories.
141+
// The path is relative to the current working directory (internal/exec).
142+
mockComponentPath, err := filepath.Abs("../../tests/fixtures/components/terraform/mock")
143+
require.NoError(t, err)
144+
145+
defer func() {
146+
// Clean up terraform state files.
147+
err := os.RemoveAll(filepath.Join(mockComponentPath, ".terraform"))
148+
assert.NoError(t, err)
149+
150+
err = os.RemoveAll(filepath.Join(mockComponentPath, "terraform.tfstate.d"))
151+
assert.NoError(t, err)
152+
153+
err = os.Remove(filepath.Join(mockComponentPath, "terraform.tfstate"))
154+
if err != nil && !os.IsNotExist(err) {
155+
assert.NoError(t, err)
156+
}
157+
158+
err = os.Remove(filepath.Join(mockComponentPath, "terraform.tfstate.backup"))
159+
if err != nil && !os.IsNotExist(err) {
160+
assert.NoError(t, err)
161+
}
162+
}()
163+
164+
// Define the working directory (workspaces-disabled fixture).
165+
workDir := "../../tests/fixtures/scenarios/atmos-terraform-state-yaml-function-workspaces-disabled"
166+
t.Chdir(workDir)
167+
168+
// Deploy component-1.
169+
info := schema.ConfigAndStacksInfo{
170+
StackFromArg: "",
171+
Stack: stack,
172+
StackFile: "",
173+
ComponentType: "terraform",
174+
ComponentFromArg: "component-1",
175+
SubCommand: "deploy",
176+
ProcessTemplates: true,
177+
ProcessFunctions: true,
178+
}
179+
180+
err = ExecuteTerraform(info)
181+
require.NoError(t, err, "Failed to deploy component-1")
182+
183+
// Verify that the state file is at terraform.tfstate (not terraform.tfstate.d/default/terraform.tfstate).
184+
stateFilePath := filepath.Join(mockComponentPath, "terraform.tfstate")
185+
wrongStatePath := filepath.Join(mockComponentPath, "terraform.tfstate.d", "default", "terraform.tfstate")
186+
187+
// State should exist at the correct location.
188+
_, err = os.Stat(stateFilePath)
189+
assert.NoError(t, err, "State file should exist at %s when workspaces are disabled", stateFilePath)
190+
191+
// State should NOT exist at the wrong location.
192+
_, err = os.Stat(wrongStatePath)
193+
assert.True(t, os.IsNotExist(err), "State file should NOT exist at %s when workspaces are disabled", wrongStatePath)
194+
}

internal/terraform_backend/terraform_backend_local.go

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,21 +13,34 @@ import (
1313

1414
// ReadTerraformBackendLocal reads the Terraform state file from the local backend.
1515
// If the state file does not exist, the function returns `nil`.
16+
//
17+
// According to Terraform local backend behavior:
18+
// - For the default workspace: state is stored at `terraform.tfstate`
19+
// - For named workspaces: state is stored at `terraform.tfstate.d/<workspace>/terraform.tfstate`
20+
//
21+
// See: https://github.com/cloudposse/atmos/issues/1920
1622
func ReadTerraformBackendLocal(
1723
atmosConfig *schema.AtmosConfiguration,
1824
componentSections *map[string]any,
1925
_ *schema.AuthContext, // Auth context not used for local backend.
2026
) ([]byte, error) {
2127
defer perf.Track(atmosConfig, "terraform_backend.ReadTerraformBackendLocal")()
2228

23-
tfStateFilePath := filepath.Join(
29+
workspace := GetTerraformWorkspace(componentSections)
30+
componentPath := filepath.Join(
2431
atmosConfig.TerraformDirAbsolutePath,
2532
GetTerraformComponent(componentSections),
26-
"terraform.tfstate.d",
27-
GetTerraformWorkspace(componentSections),
28-
"terraform.tfstate",
2933
)
3034

35+
var tfStateFilePath string
36+
if workspace == "" || workspace == "default" {
37+
// Default workspace: state is stored directly at terraform.tfstate.
38+
tfStateFilePath = filepath.Join(componentPath, "terraform.tfstate")
39+
} else {
40+
// Named workspace: state is stored at terraform.tfstate.d/<workspace>/terraform.tfstate.
41+
tfStateFilePath = filepath.Join(componentPath, "terraform.tfstate.d", workspace, "terraform.tfstate")
42+
}
43+
3144
// If the state file does not exist (the component in the stack has not been provisioned yet), return a `nil` result and no error.
3245
if !u.FileExists(tfStateFilePath) {
3346
return nil, nil

internal/terraform_backend/terraform_backend_local_test.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,120 @@ func TestGetTerraformBackendLocal(t *testing.T) {
9898
})
9999
}
100100
}
101+
102+
// TestReadTerraformBackendLocal_DefaultWorkspace verifies that when workspace
103+
// is "default" (meaning workspaces are disabled), the state file path should be
104+
// just terraform.tfstate, not terraform.tfstate.d/default/terraform.tfstate.
105+
//
106+
// This is based on Terraform local backend behavior:
107+
// - For the default workspace, state is stored directly at terraform.tfstate
108+
// - For named workspaces, state is stored at terraform.tfstate.d/<workspace>/terraform.tfstate
109+
//
110+
// See: https://github.com/cloudposse/atmos/issues/1920
111+
func TestReadTerraformBackendLocal_DefaultWorkspace(t *testing.T) {
112+
tests := []struct {
113+
name string
114+
workspace string
115+
stateLocation string // Where to place the state file (relative to component dir).
116+
expected string // Expected output value.
117+
}{
118+
{
119+
name: "default workspace - state at root",
120+
workspace: "default",
121+
stateLocation: "terraform.tfstate",
122+
expected: "default-workspace-value",
123+
},
124+
{
125+
name: "empty workspace - state at root",
126+
workspace: "",
127+
stateLocation: "terraform.tfstate",
128+
expected: "empty-workspace-value",
129+
},
130+
{
131+
name: "named workspace - state in workspace dir",
132+
workspace: "prod-us-east-1",
133+
stateLocation: "terraform.tfstate.d/prod-us-east-1/terraform.tfstate",
134+
expected: "named-workspace-value",
135+
},
136+
}
137+
138+
for _, tt := range tests {
139+
t.Run(tt.name, func(t *testing.T) {
140+
tempDir := t.TempDir()
141+
componentDir := filepath.Join(tempDir, "terraform", "test-component")
142+
143+
// Create the state file at the expected location.
144+
stateFilePath := filepath.Join(componentDir, tt.stateLocation)
145+
err := os.MkdirAll(filepath.Dir(stateFilePath), 0o755)
146+
require.NoError(t, err)
147+
148+
stateContent := `{
149+
"version": 4,
150+
"terraform_version": "1.0.0",
151+
"outputs": {
152+
"test_output": {
153+
"value": "` + tt.expected + `",
154+
"type": "string"
155+
}
156+
}
157+
}`
158+
err = os.WriteFile(stateFilePath, []byte(stateContent), 0o644)
159+
require.NoError(t, err)
160+
161+
config := &schema.AtmosConfiguration{
162+
TerraformDirAbsolutePath: filepath.Join(tempDir, "terraform"),
163+
}
164+
componentData := map[string]any{
165+
"component": "test-component",
166+
"workspace": tt.workspace,
167+
}
168+
169+
content, err := tb.ReadTerraformBackendLocal(config, &componentData, nil)
170+
require.NoError(t, err)
171+
require.NotNil(t, content, "Expected to find state file at %s", tt.stateLocation)
172+
173+
result, err := tb.ProcessTerraformStateFile(content)
174+
require.NoError(t, err)
175+
assert.Equal(t, tt.expected, result["test_output"],
176+
"For workspace '%s', expected state from '%s'", tt.workspace, tt.stateLocation)
177+
})
178+
}
179+
}
180+
181+
// TestReadTerraformBackendLocal_DefaultWorkspace_WrongLocation verifies that
182+
// when workspace is "default", we do NOT look in terraform.tfstate.d/default/.
183+
func TestReadTerraformBackendLocal_DefaultWorkspace_WrongLocation(t *testing.T) {
184+
tempDir := t.TempDir()
185+
componentDir := filepath.Join(tempDir, "terraform", "test-component")
186+
187+
// Create state file in the WRONG location (terraform.tfstate.d/default/).
188+
wrongLocation := filepath.Join(componentDir, "terraform.tfstate.d", "default", "terraform.tfstate")
189+
err := os.MkdirAll(filepath.Dir(wrongLocation), 0o755)
190+
require.NoError(t, err)
191+
192+
stateContent := `{
193+
"version": 4,
194+
"terraform_version": "1.0.0",
195+
"outputs": {
196+
"test_output": {
197+
"value": "wrong-location-value",
198+
"type": "string"
199+
}
200+
}
201+
}`
202+
err = os.WriteFile(wrongLocation, []byte(stateContent), 0o644)
203+
require.NoError(t, err)
204+
205+
config := &schema.AtmosConfiguration{
206+
TerraformDirAbsolutePath: filepath.Join(tempDir, "terraform"),
207+
}
208+
componentData := map[string]any{
209+
"component": "test-component",
210+
"workspace": "default",
211+
}
212+
213+
// Should NOT find the state file since it's in the wrong location.
214+
content, err := tb.ReadTerraformBackendLocal(config, &componentData, nil)
215+
require.NoError(t, err)
216+
assert.Nil(t, content, "Should not find state file in terraform.tfstate.d/default/ for default workspace")
217+
}

internal/terraform_backend/terraform_backend_s3.go

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -120,13 +120,27 @@ func ReadTerraformBackendS3Internal(
120120
defer perf.Track(nil, "terraform_backend.ReadTerraformBackendS3Internal")()
121121

122122
// Path to the tfstate file in the s3 bucket.
123-
// S3 paths always use forward slashes, so path.Join is appropriate here.
124-
//nolint:forbidigo // S3 paths require forward slashes regardless of OS
125-
tfStateFilePath := path.Join(
126-
GetBackendAttribute(backend, "workspace_key_prefix"),
127-
GetTerraformWorkspace(componentSections),
128-
GetBackendAttribute(backend, "key"),
129-
)
123+
// According to Terraform S3 backend documentation:
124+
// - workspace_key_prefix is only used for non-default workspaces
125+
// - For the default workspace, state is stored directly at the key path
126+
// See: https://github.com/cloudposse/atmos/issues/1920
127+
workspace := GetTerraformWorkspace(componentSections)
128+
key := GetBackendAttribute(backend, "key")
129+
130+
var tfStateFilePath string
131+
if workspace == "" || workspace == "default" {
132+
// Default workspace: state is stored directly at the key path.
133+
tfStateFilePath = key
134+
} else {
135+
// Named workspace: state is stored at workspace_key_prefix/workspace/key.
136+
// S3 paths always use forward slashes, so path.Join is appropriate here.
137+
//nolint:forbidigo // S3 paths require forward slashes regardless of OS
138+
tfStateFilePath = path.Join(
139+
GetBackendAttribute(backend, "workspace_key_prefix"),
140+
workspace,
141+
key,
142+
)
143+
}
130144

131145
bucket := GetBackendAttribute(backend, "bucket")
132146

0 commit comments

Comments
 (0)