diff --git a/internal/exec/yaml_func_terraform_state_workspaces_disabled_test.go b/internal/exec/yaml_func_terraform_state_workspaces_disabled_test.go new file mode 100644 index 0000000000..c5c421dd2b --- /dev/null +++ b/internal/exec/yaml_func_terraform_state_workspaces_disabled_test.go @@ -0,0 +1,194 @@ +package exec + +import ( + "os" + "path/filepath" + "testing" + + log "github.com/cloudposse/atmos/pkg/logger" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + cfg "github.com/cloudposse/atmos/pkg/config" + "github.com/cloudposse/atmos/pkg/schema" + u "github.com/cloudposse/atmos/pkg/utils" +) + +// TestYamlFuncTerraformStateWorkspacesDisabled tests that the !terraform.state YAML function +// works correctly when Terraform workspaces are disabled (workspaces_enabled: false). +// +// When workspaces are disabled: +// - The workspace name is "default" +// - For local backend: state is stored at terraform.tfstate (not terraform.tfstate.d/default/terraform.tfstate) +// - For S3 backend: state is stored at (not /default/) +// +// See: https://github.com/cloudposse/atmos/issues/1920 +func TestYamlFuncTerraformStateWorkspacesDisabled(t *testing.T) { + err := os.Unsetenv("ATMOS_CLI_CONFIG_PATH") + if err != nil { + t.Fatalf("Failed to unset 'ATMOS_CLI_CONFIG_PATH': %v", err) + } + + err = os.Unsetenv("ATMOS_BASE_PATH") + if err != nil { + t.Fatalf("Failed to unset 'ATMOS_BASE_PATH': %v", err) + } + + log.SetLevel(log.InfoLevel) + log.SetOutput(os.Stdout) + + stack := "test" + + defer func() { + // Delete the generated files and folders after the test. + mockComponentPath := filepath.Join("..", "..", "tests", "fixtures", "components", "terraform", "mock") + // Clean up terraform state files. + err := os.RemoveAll(filepath.Join(mockComponentPath, ".terraform")) + assert.NoError(t, err) + + err = os.RemoveAll(filepath.Join(mockComponentPath, "terraform.tfstate.d")) + assert.NoError(t, err) + + // When workspaces are disabled, state is stored at terraform.tfstate (not in terraform.tfstate.d/). + err = os.Remove(filepath.Join(mockComponentPath, "terraform.tfstate")) + // Ignore error if file doesn't exist. + if err != nil && !os.IsNotExist(err) { + assert.NoError(t, err) + } + + err = os.Remove(filepath.Join(mockComponentPath, "terraform.tfstate.backup")) + // Ignore error if file doesn't exist. + if err != nil && !os.IsNotExist(err) { + assert.NoError(t, err) + } + }() + + // Define the working directory (workspaces-disabled fixture). + workDir := "../../tests/fixtures/scenarios/atmos-terraform-state-yaml-function-workspaces-disabled" + t.Chdir(workDir) + + // Deploy component-1 first to create terraform state. + info := schema.ConfigAndStacksInfo{ + StackFromArg: "", + Stack: stack, + StackFile: "", + ComponentType: "terraform", + ComponentFromArg: "component-1", + SubCommand: "deploy", + ProcessTemplates: true, + ProcessFunctions: true, + } + + err = ExecuteTerraform(info) + require.NoError(t, err, "Failed to execute 'ExecuteTerraform' for component-1") + + // Initialize CLI config. + atmosConfig, err := cfg.InitCliConfig(info, true) + require.NoError(t, err) + + // Verify that workspaces are disabled. + require.NotNil(t, atmosConfig.Components.Terraform.WorkspacesEnabled) + assert.False(t, *atmosConfig.Components.Terraform.WorkspacesEnabled, + "Expected workspaces to be disabled in this test fixture") + + // Test !terraform.state can read outputs from component-1. + // When workspaces are disabled, the state should be at terraform.tfstate (not terraform.tfstate.d/default/terraform.tfstate). + d, err := processTagTerraformState(&atmosConfig, "!terraform.state component-1 foo", stack, nil) + require.NoError(t, err) + assert.Equal(t, "component-1-a", d, "Expected to read 'foo' output from component-1 state") + + d, err = processTagTerraformState(&atmosConfig, "!terraform.state component-1 bar", stack, nil) + require.NoError(t, err) + assert.Equal(t, "component-1-b", d, "Expected to read 'bar' output from component-1 state") + + d, err = processTagTerraformState(&atmosConfig, "!terraform.state component-1 test baz", "", nil) + require.NoError(t, err) + assert.Equal(t, "component-1-c", d, "Expected to read 'baz' output from component-1 state") + + // Verify component-2 can use !terraform.state to reference component-1's outputs. + res, err := ExecuteDescribeComponent(&ExecuteDescribeComponentParams{ + Component: "component-2", + Stack: stack, + ProcessTemplates: true, + ProcessYamlFunctions: true, + Skip: nil, + AuthManager: nil, + }) + require.NoError(t, err) + + y, err := u.ConvertToYAML(res) + require.NoError(t, err) + assert.Contains(t, y, "foo: component-1-a", "component-2 should have foo from component-1 state") + assert.Contains(t, y, "bar: component-1-b", "component-2 should have bar from component-1 state") + assert.Contains(t, y, "baz: component-1-c", "component-2 should have baz from component-1 state") +} + +// TestWorkspacesDisabledStateLocation verifies that when workspaces are disabled, +// the terraform state is stored at the correct location (terraform.tfstate, not terraform.tfstate.d/default/terraform.tfstate). +func TestWorkspacesDisabledStateLocation(t *testing.T) { + err := os.Unsetenv("ATMOS_CLI_CONFIG_PATH") + require.NoError(t, err) + + err = os.Unsetenv("ATMOS_BASE_PATH") + require.NoError(t, err) + + log.SetLevel(log.InfoLevel) + log.SetOutput(os.Stdout) + + stack := "test" + + // Get the absolute path to the mock component before changing directories. + // The path is relative to the current working directory (internal/exec). + mockComponentPath, err := filepath.Abs("../../tests/fixtures/components/terraform/mock") + require.NoError(t, err) + + defer func() { + // Clean up terraform state files. + err := os.RemoveAll(filepath.Join(mockComponentPath, ".terraform")) + assert.NoError(t, err) + + err = os.RemoveAll(filepath.Join(mockComponentPath, "terraform.tfstate.d")) + assert.NoError(t, err) + + err = os.Remove(filepath.Join(mockComponentPath, "terraform.tfstate")) + if err != nil && !os.IsNotExist(err) { + assert.NoError(t, err) + } + + err = os.Remove(filepath.Join(mockComponentPath, "terraform.tfstate.backup")) + if err != nil && !os.IsNotExist(err) { + assert.NoError(t, err) + } + }() + + // Define the working directory (workspaces-disabled fixture). + workDir := "../../tests/fixtures/scenarios/atmos-terraform-state-yaml-function-workspaces-disabled" + t.Chdir(workDir) + + // Deploy component-1. + info := schema.ConfigAndStacksInfo{ + StackFromArg: "", + Stack: stack, + StackFile: "", + ComponentType: "terraform", + ComponentFromArg: "component-1", + SubCommand: "deploy", + ProcessTemplates: true, + ProcessFunctions: true, + } + + err = ExecuteTerraform(info) + require.NoError(t, err, "Failed to deploy component-1") + + // Verify that the state file is at terraform.tfstate (not terraform.tfstate.d/default/terraform.tfstate). + stateFilePath := filepath.Join(mockComponentPath, "terraform.tfstate") + wrongStatePath := filepath.Join(mockComponentPath, "terraform.tfstate.d", "default", "terraform.tfstate") + + // State should exist at the correct location. + _, err = os.Stat(stateFilePath) + assert.NoError(t, err, "State file should exist at %s when workspaces are disabled", stateFilePath) + + // State should NOT exist at the wrong location. + _, err = os.Stat(wrongStatePath) + assert.True(t, os.IsNotExist(err), "State file should NOT exist at %s when workspaces are disabled", wrongStatePath) +} diff --git a/internal/terraform_backend/terraform_backend_local.go b/internal/terraform_backend/terraform_backend_local.go index 4abbf9e214..5de6fded22 100644 --- a/internal/terraform_backend/terraform_backend_local.go +++ b/internal/terraform_backend/terraform_backend_local.go @@ -13,6 +13,12 @@ import ( // ReadTerraformBackendLocal reads the Terraform state file from the local backend. // If the state file does not exist, the function returns `nil`. +// +// According to Terraform local backend behavior: +// - For the default workspace: state is stored at `terraform.tfstate` +// - For named workspaces: state is stored at `terraform.tfstate.d//terraform.tfstate` +// +// See: https://github.com/cloudposse/atmos/issues/1920 func ReadTerraformBackendLocal( atmosConfig *schema.AtmosConfiguration, componentSections *map[string]any, @@ -20,14 +26,21 @@ func ReadTerraformBackendLocal( ) ([]byte, error) { defer perf.Track(atmosConfig, "terraform_backend.ReadTerraformBackendLocal")() - tfStateFilePath := filepath.Join( + workspace := GetTerraformWorkspace(componentSections) + componentPath := filepath.Join( atmosConfig.TerraformDirAbsolutePath, GetTerraformComponent(componentSections), - "terraform.tfstate.d", - GetTerraformWorkspace(componentSections), - "terraform.tfstate", ) + var tfStateFilePath string + if workspace == "" || workspace == "default" { + // Default workspace: state is stored directly at terraform.tfstate. + tfStateFilePath = filepath.Join(componentPath, "terraform.tfstate") + } else { + // Named workspace: state is stored at terraform.tfstate.d//terraform.tfstate. + tfStateFilePath = filepath.Join(componentPath, "terraform.tfstate.d", workspace, "terraform.tfstate") + } + // If the state file does not exist (the component in the stack has not been provisioned yet), return a `nil` result and no error. if !u.FileExists(tfStateFilePath) { return nil, nil diff --git a/internal/terraform_backend/terraform_backend_local_test.go b/internal/terraform_backend/terraform_backend_local_test.go index d7f37ea6b1..f2104eabb3 100644 --- a/internal/terraform_backend/terraform_backend_local_test.go +++ b/internal/terraform_backend/terraform_backend_local_test.go @@ -98,3 +98,120 @@ func TestGetTerraformBackendLocal(t *testing.T) { }) } } + +// TestReadTerraformBackendLocal_DefaultWorkspace verifies that when workspace +// is "default" (meaning workspaces are disabled), the state file path should be +// just terraform.tfstate, not terraform.tfstate.d/default/terraform.tfstate. +// +// This is based on Terraform local backend behavior: +// - For the default workspace, state is stored directly at terraform.tfstate +// - For named workspaces, state is stored at terraform.tfstate.d//terraform.tfstate +// +// See: https://github.com/cloudposse/atmos/issues/1920 +func TestReadTerraformBackendLocal_DefaultWorkspace(t *testing.T) { + tests := []struct { + name string + workspace string + stateLocation string // Where to place the state file (relative to component dir). + expected string // Expected output value. + }{ + { + name: "default workspace - state at root", + workspace: "default", + stateLocation: "terraform.tfstate", + expected: "default-workspace-value", + }, + { + name: "empty workspace - state at root", + workspace: "", + stateLocation: "terraform.tfstate", + expected: "empty-workspace-value", + }, + { + name: "named workspace - state in workspace dir", + workspace: "prod-us-east-1", + stateLocation: "terraform.tfstate.d/prod-us-east-1/terraform.tfstate", + expected: "named-workspace-value", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tempDir := t.TempDir() + componentDir := filepath.Join(tempDir, "terraform", "test-component") + + // Create the state file at the expected location. + stateFilePath := filepath.Join(componentDir, tt.stateLocation) + err := os.MkdirAll(filepath.Dir(stateFilePath), 0o755) + require.NoError(t, err) + + stateContent := `{ + "version": 4, + "terraform_version": "1.0.0", + "outputs": { + "test_output": { + "value": "` + tt.expected + `", + "type": "string" + } + } + }` + err = os.WriteFile(stateFilePath, []byte(stateContent), 0o644) + require.NoError(t, err) + + config := &schema.AtmosConfiguration{ + TerraformDirAbsolutePath: filepath.Join(tempDir, "terraform"), + } + componentData := map[string]any{ + "component": "test-component", + "workspace": tt.workspace, + } + + content, err := tb.ReadTerraformBackendLocal(config, &componentData, nil) + require.NoError(t, err) + require.NotNil(t, content, "Expected to find state file at %s", tt.stateLocation) + + result, err := tb.ProcessTerraformStateFile(content) + require.NoError(t, err) + assert.Equal(t, tt.expected, result["test_output"], + "For workspace '%s', expected state from '%s'", tt.workspace, tt.stateLocation) + }) + } +} + +// TestReadTerraformBackendLocal_DefaultWorkspace_WrongLocation verifies that +// when workspace is "default", we do NOT look in terraform.tfstate.d/default/. +func TestReadTerraformBackendLocal_DefaultWorkspace_WrongLocation(t *testing.T) { + tempDir := t.TempDir() + componentDir := filepath.Join(tempDir, "terraform", "test-component") + + // Create state file in the WRONG location (terraform.tfstate.d/default/). + wrongLocation := filepath.Join(componentDir, "terraform.tfstate.d", "default", "terraform.tfstate") + err := os.MkdirAll(filepath.Dir(wrongLocation), 0o755) + require.NoError(t, err) + + stateContent := `{ + "version": 4, + "terraform_version": "1.0.0", + "outputs": { + "test_output": { + "value": "wrong-location-value", + "type": "string" + } + } + }` + err = os.WriteFile(wrongLocation, []byte(stateContent), 0o644) + require.NoError(t, err) + + config := &schema.AtmosConfiguration{ + TerraformDirAbsolutePath: filepath.Join(tempDir, "terraform"), + } + componentData := map[string]any{ + "component": "test-component", + "workspace": "default", + } + + // Should NOT find the state file since it's in the wrong location. + content, err := tb.ReadTerraformBackendLocal(config, &componentData, nil) + require.NoError(t, err) + assert.Nil(t, content, "Should not find state file in terraform.tfstate.d/default/ for default workspace") +} diff --git a/internal/terraform_backend/terraform_backend_s3.go b/internal/terraform_backend/terraform_backend_s3.go index e2ffa71ad9..b6ee4ab7d7 100644 --- a/internal/terraform_backend/terraform_backend_s3.go +++ b/internal/terraform_backend/terraform_backend_s3.go @@ -120,13 +120,27 @@ func ReadTerraformBackendS3Internal( defer perf.Track(nil, "terraform_backend.ReadTerraformBackendS3Internal")() // Path to the tfstate file in the s3 bucket. - // S3 paths always use forward slashes, so path.Join is appropriate here. - //nolint:forbidigo // S3 paths require forward slashes regardless of OS - tfStateFilePath := path.Join( - GetBackendAttribute(backend, "workspace_key_prefix"), - GetTerraformWorkspace(componentSections), - GetBackendAttribute(backend, "key"), - ) + // According to Terraform S3 backend documentation: + // - workspace_key_prefix is only used for non-default workspaces + // - For the default workspace, state is stored directly at the key path + // See: https://github.com/cloudposse/atmos/issues/1920 + workspace := GetTerraformWorkspace(componentSections) + key := GetBackendAttribute(backend, "key") + + var tfStateFilePath string + if workspace == "" || workspace == "default" { + // Default workspace: state is stored directly at the key path. + tfStateFilePath = key + } else { + // Named workspace: state is stored at workspace_key_prefix/workspace/key. + // S3 paths always use forward slashes, so path.Join is appropriate here. + //nolint:forbidigo // S3 paths require forward slashes regardless of OS + tfStateFilePath = path.Join( + GetBackendAttribute(backend, "workspace_key_prefix"), + workspace, + key, + ) + } bucket := GetBackendAttribute(backend, "bucket") diff --git a/internal/terraform_backend/terraform_backend_s3_test.go b/internal/terraform_backend/terraform_backend_s3_test.go index 1e71b9136b..96d7634e06 100644 --- a/internal/terraform_backend/terraform_backend_s3_test.go +++ b/internal/terraform_backend/terraform_backend_s3_test.go @@ -270,6 +270,116 @@ func Test_ReadTerraformBackendS3Internal_Errors(t *testing.T) { } } +// mockS3ClientForDefaultWorkspace is a mock that captures the requested key +// to verify path construction for default workspace. +type mockS3ClientForDefaultWorkspace struct { + requestedKey string +} + +func (m *mockS3ClientForDefaultWorkspace) GetObject(ctx context.Context, input *s3.GetObjectInput, _ ...func(*s3.Options)) (*s3.GetObjectOutput, error) { + m.requestedKey = *input.Key + // Return valid state for the expected key. + body := `{ + "version": 4, + "terraform_version": "1.4.0", + "outputs": { + "vpc_id": { + "value": "vpc-12345", + "type": "string" + } + } + }` + return &s3.GetObjectOutput{ + Body: io.NopCloser(strings.NewReader(body)), + }, nil +} + +// TestReadTerraformBackendS3Internal_DefaultWorkspace verifies that when workspace +// is "default" (meaning workspaces are disabled), the state file path should be +// just the key, not workspace_key_prefix/default/key. +// +// This is based on Terraform S3 backend documentation: +// - workspace_key_prefix is only used for non-default workspaces +// - For the default workspace, state is stored directly at the key path. +// +// See: https://github.com/cloudposse/atmos/issues/1920 +func TestReadTerraformBackendS3Internal_DefaultWorkspace(t *testing.T) { + tests := []struct { + name string + workspace string + workspaceKeyPrefix string + key string + expectedPath string + }{ + { + name: "default workspace - should use key only", + workspace: "default", + workspaceKeyPrefix: "my-component", + key: "terraform.tfstate", + expectedPath: "terraform.tfstate", + }, + { + name: "default workspace with env prefix - should use key only", + workspace: "default", + workspaceKeyPrefix: "env:", + key: "state/terraform.tfstate", + expectedPath: "state/terraform.tfstate", + }, + { + name: "named workspace - should use full path", + workspace: "prod-us-east-1", + workspaceKeyPrefix: "my-component", + key: "terraform.tfstate", + expectedPath: "my-component/prod-us-east-1/terraform.tfstate", + }, + { + name: "named workspace with env prefix - should use full path", + workspace: "staging", + workspaceKeyPrefix: "env:", + key: "terraform.tfstate", + expectedPath: "env:/staging/terraform.tfstate", + }, + { + name: "empty workspace_key_prefix with named workspace", + workspace: "prod", + workspaceKeyPrefix: "", + key: "terraform.tfstate", + expectedPath: "prod/terraform.tfstate", + }, + { + name: "empty workspace_key_prefix with default workspace", + workspace: "default", + workspaceKeyPrefix: "", + key: "terraform.tfstate", + expectedPath: "terraform.tfstate", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := &mockS3ClientForDefaultWorkspace{} + componentSections := map[string]any{ + "workspace": tt.workspace, + } + backend := map[string]any{ + "bucket": "test-bucket", + "region": "us-east-1", + "key": tt.key, + "workspace_key_prefix": tt.workspaceKeyPrefix, + } + + // Call the function. + _, err := tb.ReadTerraformBackendS3Internal(client, &componentSections, &backend) + assert.NoError(t, err) + + // Verify the requested path matches the expected path. + assert.Equal(t, tt.expectedPath, client.requestedKey, + "For workspace '%s' with workspace_key_prefix '%s', expected path '%s' but got '%s'", + tt.workspace, tt.workspaceKeyPrefix, tt.expectedPath, client.requestedKey) + }) + } +} + func TestGetS3BackendAssumeRoleArn(t *testing.T) { tests := []struct { name string diff --git a/internal/terraform_backend/terraform_backend_utils_test.go b/internal/terraform_backend/terraform_backend_utils_test.go index 7f63eed1e8..410c57b359 100644 --- a/internal/terraform_backend/terraform_backend_utils_test.go +++ b/internal/terraform_backend/terraform_backend_utils_test.go @@ -243,6 +243,26 @@ func TestGetTerraformBackend(t *testing.T) { expectedOutputs: map[string]any{"value": "default-backend-output"}, expectError: false, }, + { + name: "named workspace with state data", + componentData: map[string]any{ + "component": "sample-component", + "workspace": "staging", + "backend_type": "", + }, + stateJSON: `{ + "version": 4, + "terraform_version": "1.3.0", + "outputs": { + "value": { + "value": "staging-output", + "type": "string" + } + } + }`, + expectedOutputs: map[string]any{"value": "staging-output"}, + expectError: false, + }, { name: "unsupported backend type", componentData: map[string]any{ @@ -256,16 +276,29 @@ func TestGetTerraformBackend(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Create a temp directory and write the test state file to simulate local backend + // Create a temp directory and write the test state file to simulate local backend. tmpDir := t.TempDir() componentDir := filepath.Join(tmpDir, "terraform", tt.componentData["component"].(string)) - stateDir := filepath.Join(componentDir, "terraform.tfstate.d", tt.componentData["workspace"].(string)) if tt.stateJSON != "" { - err := os.MkdirAll(stateDir, 0o755) + err := os.MkdirAll(componentDir, 0o755) assert.NoError(t, err) - err = os.WriteFile(filepath.Join(stateDir, "terraform.tfstate"), []byte(tt.stateJSON), 0o644) + // Determine state file path based on workspace. + // For default workspace: terraform.tfstate + // For named workspaces: terraform.tfstate.d//terraform.tfstate + workspace := tt.componentData["workspace"].(string) + var stateFilePath string + if workspace == "" || workspace == "default" { + stateFilePath = filepath.Join(componentDir, "terraform.tfstate") + } else { + stateDir := filepath.Join(componentDir, "terraform.tfstate.d", workspace) + err = os.MkdirAll(stateDir, 0o755) + assert.NoError(t, err) + stateFilePath = filepath.Join(stateDir, "terraform.tfstate") + } + + err = os.WriteFile(stateFilePath, []byte(tt.stateJSON), 0o644) assert.NoError(t, err) } diff --git a/tests/fixtures/scenarios/atmos-terraform-state-yaml-function-workspaces-disabled/atmos.yaml b/tests/fixtures/scenarios/atmos-terraform-state-yaml-function-workspaces-disabled/atmos.yaml new file mode 100644 index 0000000000..8e96f90590 --- /dev/null +++ b/tests/fixtures/scenarios/atmos-terraform-state-yaml-function-workspaces-disabled/atmos.yaml @@ -0,0 +1,41 @@ +base_path: "./" + +components: + terraform: + base_path: "../../components/terraform" + apply_auto_approve: false + deploy_run_init: true + init_run_reconfigure: true + auto_generate_backend_file: false + # Workspaces disabled - state is stored directly at the key path + # rather than at workspace_key_prefix/workspace/key + workspaces_enabled: false + +stacks: + base_path: "stacks" + included_paths: + - "deploy/**/*" + excluded_paths: + - "**/_defaults.yaml" + name_template: "{{ .vars.stage }}" + +logs: + file: "/dev/stderr" + level: Info + +# `Go` templates in Atmos manifests +# https://atmos.tools/core-concepts/stacks/templates +# https://pkg.go.dev/text/template +templates: + settings: + enabled: true + evaluations: 1 + # https://masterminds.github.io/sprig + sprig: + enabled: true + # https://docs.gomplate.ca + gomplate: + enabled: true + timeout: 10 + # https://docs.gomplate.ca/datasources + datasources: {} diff --git a/tests/fixtures/scenarios/atmos-terraform-state-yaml-function-workspaces-disabled/stacks/deploy/test.yaml b/tests/fixtures/scenarios/atmos-terraform-state-yaml-function-workspaces-disabled/stacks/deploy/test.yaml new file mode 100644 index 0000000000..f0f8d62f43 --- /dev/null +++ b/tests/fixtures/scenarios/atmos-terraform-state-yaml-function-workspaces-disabled/stacks/deploy/test.yaml @@ -0,0 +1,39 @@ +# yaml-language-server: $schema=https://atmos.tools/schemas/atmos/atmos-manifest/1.0/atmos-manifest.json + +# Test fixture for !terraform.state with workspaces disabled. +# When workspaces_enabled: false, the workspace is "default" and Terraform +# stores state directly at terraform.tfstate (not terraform.tfstate.d/default/terraform.tfstate). +# See: https://github.com/cloudposse/atmos/issues/1920 + +vars: + stage: test + +# Using local backend for testing. +# When workspaces are disabled: +# - Local backend: state at terraform.tfstate (not terraform.tfstate.d/default/terraform.tfstate) +# - S3 backend: state at (not /default/) + +components: + terraform: + component-1: + metadata: + component: mock + settings: + config: + a: component-1-a + b: component-1-b + c: component-1-c + vars: + foo: "{{ .settings.config.a }}" + bar: "{{ .settings.config.b }}" + baz: "{{ .settings.config.c }}" + + component-2: + metadata: + component: mock + vars: + # When workspaces are disabled, !terraform.state should look for state + # at the key path (terraform.tfstate), not at components/default/terraform.tfstate. + foo: !terraform.state component-1 foo + bar: !terraform.state component-1 bar + baz: !terraform.state component-1 {{ .stack }} baz diff --git a/website/blog/2026-01-02-terraform-state-workspaces-disabled-fix.mdx b/website/blog/2026-01-02-terraform-state-workspaces-disabled-fix.mdx new file mode 100644 index 0000000000..8189019bf2 --- /dev/null +++ b/website/blog/2026-01-02-terraform-state-workspaces-disabled-fix.mdx @@ -0,0 +1,96 @@ +--- +slug: terraform-state-workspaces-disabled-fix +title: 'Fixed: !terraform.state with Disabled Workspaces' +authors: + - aknysh +tags: + - bugfix +date: 2026-01-02T00:00:00.000Z +--- + +The `!terraform.state` YAML function now correctly reads Terraform state when workspaces are disabled +(`components.terraform.workspaces_enabled: false` in `atmos.yaml`). +Previously, Atmos looked for state files in the wrong location, causing the function to fail. + + + +## The Problem + +When `workspaces_enabled: false` is set in `atmos.yaml`, Atmos sets the workspace name to `default`. +However, Terraform stores state differently for the default workspace compared to named workspaces: + +| Backend | Default Workspace | Named Workspace | +|-----------|---------------------|-----------------------------------------------------| +| **S3** | `` | `//` | +| **Local** | `terraform.tfstate` | `terraform.tfstate.d//terraform.tfstate` | +| **Azure** | `` | `env:` | + +Atmos was incorrectly looking for state at the named workspace path even when using the default workspace. For example: + +```yaml +# atmos.yaml +components: + terraform: + workspaces_enabled: false # Uses "default" workspace +``` + +```yaml +# Stack manifest +components: + terraform: + my-component: + vars: + vpc_id: !terraform.state vpc output_id # Failed to find state! +``` + +The `!terraform.state` function would look for state at `workspace_key_prefix/default/key` (S3) +or `terraform.tfstate.d/default/terraform.tfstate` (local) instead of the correct location. + +## The Fix + +Atmos now correctly handles the default workspace for all backend types: + +- **S3 backend**: Uses `` directly instead of `/default/` +- **Local backend**: Uses `terraform.tfstate` instead of `terraform.tfstate.d/default/terraform.tfstate` +- **Azure backend**: Already worked correctly + +## Example + +With workspaces disabled, the `!terraform.state` function now works as expected: + +```yaml +# atmos.yaml +components: + terraform: + workspaces_enabled: false + +# Stack manifest +components: + terraform: + networking: + metadata: + component: vpc + vars: + cidr: "10.0.0.0/16" + + application: + metadata: + component: app + vars: + # Now correctly reads from terraform.tfstate (not terraform.tfstate.d/default/terraform.tfstate) + vpc_id: !terraform.state networking vpc_id + subnet_ids: !terraform.state networking private_subnet_ids +``` + +## Upgrade + +Upgrade Atmos to get this fix. No configuration changes are required. +The `!terraform.state` function will automatically use the correct state file paths based on your workspace configuration. + +## References + +- [GitHub Issue #1920](https://github.com/cloudposse/atmos/issues/1920) +- [Terraform S3 Backend Documentation](https://developer.hashicorp.com/terraform/language/backend/s3) +- [Terraform Local Backend Documentation](https://developer.hashicorp.com/terraform/language/backend/local) +- [Terraform Azure Backend Documentation](https://developer.hashicorp.com/terraform/language/backend/azurerm) +- [Terraform GCS Backend Documentation](https://developer.hashicorp.com/terraform/language/backend/gcs)