Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions internal/exec/terraform_state_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,17 @@ import (

var terraformStateCache = sync.Map{}

// ResetStateCache clears the terraform state cache.
// This is exported for use in tests to ensure cache isolation between test functions.
func ResetStateCache() {
defer perf.Track(nil, "exec.ResetStateCache")()

terraformStateCache.Range(func(key, _ any) bool {
terraformStateCache.Delete(key)
return true
})
}

// GetTerraformState retrieves a specified Terraform output variable for a given component within a stack.
// It optionally uses a cache to avoid redundant state retrievals and supports both static and dynamic backends.
// Parameters:
Expand Down
7 changes: 7 additions & 0 deletions internal/exec/yaml_func_terraform_output_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,17 @@ import (

cfg "github.com/cloudposse/atmos/pkg/config"
"github.com/cloudposse/atmos/pkg/schema"
tfoutput "github.com/cloudposse/atmos/pkg/terraform/output"
u "github.com/cloudposse/atmos/pkg/utils"
)

func TestYamlFuncTerraformOutput(t *testing.T) {
// Clear caches to ensure isolation from other tests that may have run first.
tfoutput.ResetOutputsCache()
t.Cleanup(func() {
tfoutput.ResetOutputsCache()
})

if _, err := exec.LookPath("tofu"); err != nil {
if _, err2 := exec.LookPath("terraform"); err2 != nil {
t.Skip("skipping: neither 'tofu' nor 'terraform' binary found in PATH (required for !terraform.output integration test)")
Expand Down
9 changes: 9 additions & 0 deletions internal/exec/yaml_func_terraform_state_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,19 @@ import (

cfg "github.com/cloudposse/atmos/pkg/config"
"github.com/cloudposse/atmos/pkg/schema"
tfoutput "github.com/cloudposse/atmos/pkg/terraform/output"
u "github.com/cloudposse/atmos/pkg/utils"
)

func TestYamlFuncTerraformState(t *testing.T) {
// Clear caches to ensure isolation from other tests that may have run first.
ResetStateCache()
tfoutput.ResetOutputsCache()
t.Cleanup(func() {
ResetStateCache()
tfoutput.ResetOutputsCache()
})

if _, lookErr := exec.LookPath("tofu"); lookErr != nil {
if _, lookErr2 := exec.LookPath("terraform"); lookErr2 != nil {
t.Skip("skipping: neither 'tofu' nor 'terraform' binary found in PATH (required for !terraform.state integration test)")
Expand Down
9 changes: 9 additions & 0 deletions internal/exec/yaml_func_utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

cfg "github.com/cloudposse/atmos/pkg/config"
"github.com/cloudposse/atmos/pkg/schema"
tfoutput "github.com/cloudposse/atmos/pkg/terraform/output"
u "github.com/cloudposse/atmos/pkg/utils"
)

Expand Down Expand Up @@ -233,6 +234,14 @@ func TestSkipFunc_EdgeCases(t *testing.T) {
}

func TestProcessCustomYamlTags(t *testing.T) {
// Clear caches to ensure isolation from other tests that may have run first.
ResetStateCache()
tfoutput.ResetOutputsCache()
t.Cleanup(func() {
ResetStateCache()
tfoutput.ResetOutputsCache()
})

if _, lookErr := exec.LookPath("tofu"); lookErr != nil {
if _, lookErr2 := exec.LookPath("terraform"); lookErr2 != nil {
t.Skip("skipping: neither 'tofu' nor 'terraform' binary found in PATH (required for !terraform.state integration test)")
Expand Down
12 changes: 11 additions & 1 deletion internal/terraform_backend/terraform_backend_local.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
"time"

errUtils "github.com/cloudposse/atmos/errors"
"github.com/cloudposse/atmos/pkg/perf"
Expand Down Expand Up @@ -42,8 +44,16 @@ func ReadTerraformBackendLocal(
}

// If the state file does not exist (the component in the stack has not been provisioned yet), return a `nil` result and no error.
// On Windows, recently-written files may not be immediately visible due to filesystem latency.
if !u.FileExists(tfStateFilePath) {
return nil, nil
if runtime.GOOS != "windows" {
return nil, nil
}

time.Sleep(200 * time.Millisecond)
if !u.FileExists(tfStateFilePath) {
return nil, nil
}
}

content, err := os.ReadFile(tfStateFilePath)
Expand Down
13 changes: 12 additions & 1 deletion pkg/ci/plugins/terraform/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ type TerraformTemplateContext struct {
// HasDestroy indicates if there are resources to be destroyed.
HasDestroy bool

// HasOutputChanges indicates if there are terraform output value changes (without resource changes).
HasOutputChanges bool

// Warnings contains full warning block text extracted from terraform output.
Warnings []string
}
Expand All @@ -66,6 +69,7 @@ func NewTemplateContext(base *plugin.TemplateContext, data *plugin.TerraformOutp
ctx.Outputs = data.Outputs
ctx.ChangedResult = data.ChangedResult
ctx.HasDestroy = data.ResourceCounts.Destroy > 0
ctx.HasOutputChanges = len(data.Outputs) > 0
ctx.Warnings = blockquoteWarnings(data.Warnings)
}

Expand All @@ -85,10 +89,17 @@ func (c *TerraformTemplateContext) Target() string {
return c.Stack + "-" + sanitized
}

// HasChanges returns true if there are any resource changes.
// HasChanges returns true if there are any resource or output changes.
func (c *TerraformTemplateContext) HasChanges() bool {
defer perf.Track(nil, "terraform.TerraformTemplateContext.HasChanges")()

return c.HasResourceChanges() || c.HasOutputChanges
}

// HasResourceChanges returns true if there are any resource changes (create, change, replace, destroy).
func (c *TerraformTemplateContext) HasResourceChanges() bool {
defer perf.Track(nil, "terraform.TerraformTemplateContext.HasResourceChanges")()

return c.Resources.Create > 0 ||
c.Resources.Change > 0 ||
c.Resources.Replace > 0 ||
Expand Down
24 changes: 22 additions & 2 deletions pkg/ci/plugins/terraform/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ var (
// Matches "Outputs:" section header in terraform apply stdout.
outputsSectionRe = regexp.MustCompile(`(?m)^Outputs:\s*$`)

// Matches "Changes to Outputs:" section header in terraform plan stdout.
// This appears when only output values change (no resource changes).
outputChangesRe = regexp.MustCompile(`(?m)^Changes to Outputs:`)

// Matches simple output lines: 'key = "value"' or 'key = value'.
// Captures key and raw value (including quotes).
outputLineRe = regexp.MustCompile(`^(\w+)\s*=\s*(.+)$`)
Expand All @@ -75,9 +79,11 @@ func ParsePlanJSON(jsonData []byte) (*plugin.OutputResult, error) {
processResourceChanges(plan.ResourceChanges, data)
processOutputChanges(plan.OutputChanges, data)

result.HasChanges = hasResourceChanges(data.ResourceCounts)
if result.HasChanges {
result.HasChanges = hasResourceChanges(data.ResourceCounts) || len(data.Outputs) > 0
if hasResourceChanges(data.ResourceCounts) {
data.ChangedResult = buildChangeSummary(data.ResourceCounts)
} else if len(data.Outputs) > 0 {
data.ChangedResult = buildOutputChangeSummary(len(data.Outputs))
}

return result, nil
Expand Down Expand Up @@ -360,6 +366,12 @@ func ParsePlanOutput(output string) *plugin.OutputResult {
}
}

// Detect output-only changes (no resource changes but outputs changing).
if !result.HasChanges && outputChangesRe.MatchString(output) {
result.HasChanges = true
data.ChangedResult = "Output values will change. No infrastructure changes."
}

// Extract full warning blocks for CI summary display.
data.Warnings = ExtractWarningBlocks(output)

Expand Down Expand Up @@ -606,6 +618,14 @@ func buildChangeSummary(counts plugin.ResourceCounts) string {
return strings.Join(parts, ", ")
}

// buildOutputChangeSummary builds a human-readable summary for output-only changes.
func buildOutputChangeSummary(count int) string {
if count == 1 {
return "1 output to change"
}
return strconv.Itoa(count) + " outputs to change"
}

// resourceCount formats a count of resources with proper pluralization.
func resourceCount(count int) string {
if count == 1 {
Expand Down
54 changes: 54 additions & 0 deletions pkg/ci/plugins/terraform/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,11 @@ func TestParsePlanJSON(t *testing.T) {
wantHasChanges: false,
wantImportedRes: []string{"aws_instance.imported"},
},
{
name: "output-only changes",
inputFile: "testdata/plan_json/success_output_only.json",
wantHasChanges: true,
},
}

for _, tt := range tests {
Expand Down Expand Up @@ -152,6 +157,37 @@ func TestParsePlanJSON_WithOutputs(t *testing.T) {
assert.Equal(t, "my-bucket", tfData.Outputs["bucket_name"].Value)
}

func TestParsePlanJSON_OutputOnlyChanges(t *testing.T) {
data, err := os.ReadFile("testdata/plan_json/success_output_only.json")
require.NoError(t, err)

result, err := ParsePlanJSON(data)
require.NoError(t, err)

// Output-only changes should be detected as changes.
assert.True(t, result.HasChanges, "HasChanges should be true for output-only changes")
assert.False(t, result.HasErrors)

tfData, ok := result.Data.(*plugin.TerraformOutputData)
require.True(t, ok)

// No resource changes.
assert.Equal(t, 0, tfData.ResourceCounts.Create)
assert.Equal(t, 0, tfData.ResourceCounts.Change)
assert.Equal(t, 0, tfData.ResourceCounts.Replace)
assert.Equal(t, 0, tfData.ResourceCounts.Destroy)

// But outputs were parsed.
assert.Len(t, tfData.Outputs, 2)
assert.Contains(t, tfData.Outputs, "environment")
assert.Contains(t, tfData.Outputs, "app_version")
assert.Equal(t, "production", tfData.Outputs["environment"].Value)
assert.Equal(t, "2.0.0", tfData.Outputs["app_version"].Value)

// ChangedResult should describe output changes.
assert.Equal(t, "2 outputs to change", tfData.ChangedResult)
}

func TestParsePlanJSON_InvalidJSON(t *testing.T) {
_, err := ParsePlanJSON([]byte("not valid json"))
assert.Error(t, err)
Expand Down Expand Up @@ -513,6 +549,18 @@ Test warning details
"Test warning details",
},
},
{
name: "plan with output-only changes",
output: `
Changes to Outputs:
+ environment = "production"
~ app_version = "1.0.0" -> "2.0.0"

You can apply this plan to save these new output values to the Terraform state,
without changing any real infrastructure.
`,
hasChanges: true,
},
{
name: "plan with errors",
output: `
Expand Down Expand Up @@ -917,6 +965,12 @@ func TestBuildChangeSummary(t *testing.T) {
}
}

func TestBuildOutputChangeSummary(t *testing.T) {
assert.Equal(t, "1 output to change", buildOutputChangeSummary(1))
assert.Equal(t, "2 outputs to change", buildOutputChangeSummary(2))
assert.Equal(t, "5 outputs to change", buildOutputChangeSummary(5))
}

func TestFormatType(t *testing.T) {
tests := []struct {
name string
Expand Down
1 change: 1 addition & 0 deletions pkg/ci/plugins/terraform/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ func (p *Plugin) getArtifactKey(info *schema.ConfigAndStacksInfo, ciCtx *provide
var planOutputMarkers = []string{
"Terraform will perform the following actions:",
"OpenTofu will perform the following actions:",
"Changes to Outputs:",
}

// noChangesMarker identifies output where terraform found no differences.
Expand Down
70 changes: 63 additions & 7 deletions pkg/ci/plugins/terraform/template_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ func TestTemplateRendering(t *testing.T) {
},
},
wantContains: []string{
"Changes Found",
"Resource Changes Found",
"PLAN-CREATE-success",
"vpc",
"dev-us-east-1",
Expand Down Expand Up @@ -205,6 +205,37 @@ func TestTemplateRendering(t *testing.T) {
},
},

{
name: "plan with output-only changes",
templateName: "plan",
context: &TerraformTemplateContext{
TemplateContext: &plugin.TemplateContext{
Component: "config",
ComponentType: "terraform",
Stack: "dev",
Command: "plan",
Result: &plugin.OutputResult{
ExitCode: 0,
HasChanges: true,
HasErrors: false,
},
},
Resources: plugin.ResourceCounts{},
HasOutputChanges: true,
},
wantContains: []string{
"Output Changes Found",
"OUTPUT_CHANGE-blue",
"Output values will change. No infrastructure changes.",
},
wantNotContains: []string{
"NO_CHANGE",
"CREATE",
"DESTROY",
"Resource Changes Found",
},
},

// Apply scenarios.
{
name: "apply success",
Expand Down Expand Up @@ -445,7 +476,7 @@ func TestTemplateWithCIContext(t *testing.T) {
// Verify basic rendering works with CI context present.
assert.Contains(t, rendered, "vpc")
assert.Contains(t, rendered, "dev")
assert.Contains(t, rendered, "Changes Found")
assert.Contains(t, rendered, "Resource Changes Found")
}

// TestTerraformTemplateContextHelpers tests helper methods.
Expand Down Expand Up @@ -476,23 +507,48 @@ func TestTerraformTemplateContextHelpers(t *testing.T) {
})

t.Run("HasChanges", func(t *testing.T) {
tests := []struct {
name string
resources plugin.ResourceCounts
hasOutputChanges bool
want bool
}{
{"no changes", plugin.ResourceCounts{}, false, false},
{"create only", plugin.ResourceCounts{Create: 1}, false, true},
{"change only", plugin.ResourceCounts{Change: 1}, false, true},
{"replace only", plugin.ResourceCounts{Replace: 1}, false, true},
{"destroy only", plugin.ResourceCounts{Destroy: 1}, false, true},
{"mixed", plugin.ResourceCounts{Create: 1, Destroy: 1}, false, true},
{"output changes only", plugin.ResourceCounts{}, true, true},
{"resource and output changes", plugin.ResourceCounts{Create: 1}, true, true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := &TerraformTemplateContext{
Resources: tt.resources,
HasOutputChanges: tt.hasOutputChanges,
}
assert.Equal(t, tt.want, ctx.HasChanges())
})
}
})

t.Run("HasResourceChanges", func(t *testing.T) {
tests := []struct {
name string
resources plugin.ResourceCounts
want bool
}{
{"no changes", plugin.ResourceCounts{}, false},
{"create only", plugin.ResourceCounts{Create: 1}, true},
{"change only", plugin.ResourceCounts{Change: 1}, true},
{"replace only", plugin.ResourceCounts{Replace: 1}, true},
{"destroy only", plugin.ResourceCounts{Destroy: 1}, true},
{"mixed", plugin.ResourceCounts{Create: 1, Destroy: 1}, true},
{"output changes only does not count", plugin.ResourceCounts{}, false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := &TerraformTemplateContext{Resources: tt.resources}
assert.Equal(t, tt.want, ctx.HasChanges())
assert.Equal(t, tt.want, ctx.HasResourceChanges())
})
}
})
Expand Down
Loading
Loading