Skip to content
Open
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
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.1.0
0.2.0
25 changes: 25 additions & 0 deletions server/core/runtime/apply_step_runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,23 @@ func (a *ApplyStepRunner) Run(ctx command.ProjectContext, extraArgs []string, pa
out, err = a.TerraformExecutor.RunCommandWithVersion(ctx, path, args, envs, tfDistribution, tfVersion, ctx.Workspace)
}

// Handle the case where Terraform Cloud/Enterprise returns an error when
// applying a plan with no changes. This is a known issue where TFE returns
// exit code 1 with "Error: Saved plan has no changes" error. We treat this as success.
// See: https://github.com/runatlantis/atlantis/issues/4369
if err != nil && isNoChangesApplyError(out) {
ctx.Log.Info("terraform apply returned 'no changes' error, treating as success")
err = nil
// Update output to indicate successful no-op apply if not already present
if !strings.Contains(strings.ToLower(out), "apply complete") {
if out != "" {
out = out + "\n\nApply complete! Resources: 0 added, 0 changed, 0 destroyed.\n"
} else {
out = "Apply complete! Resources: 0 added, 0 changed, 0 destroyed.\n"
}
}
}

// If the apply was successful, delete the plan.
if err == nil {
ctx.Log.Info("apply successful, deleting planfile")
Expand All @@ -76,6 +93,14 @@ func (a *ApplyStepRunner) Run(ctx command.ProjectContext, extraArgs []string, pa
return out, err
}

// isNoChangesApplyError checks if the error from terraform apply is due to
// a plan having no changes. This is particularly relevant for Terraform
// Cloud/Enterprise which returns an error when applying a plan with no changes.
func isNoChangesApplyError(output string) bool {
// Check for the exact Terraform Cloud/Enterprise error message
return strings.Contains(strings.ToLower(output), "error: saved plan has no changes")
}

func (a *ApplyStepRunner) hasTargetFlag(ctx command.ProjectContext, extraArgs []string) bool {
isTargetFlag := func(s string) bool {
if s == "-target" {
Expand Down
107 changes: 107 additions & 0 deletions server/core/runtime/apply_step_runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -492,3 +492,110 @@ var noConfirmationOut = `

Error: Apply discarded.
`

// Test that apply succeeds when TFE returns "Error: Saved plan has no changes"
func TestRun_NoChanges_TFE_Success(t *testing.T) {
tmpDir := t.TempDir()
planPath := filepath.Join(tmpDir, "workspace.tfplan")
err := os.WriteFile(planPath, nil, 0600)
logger := logging.NewNoopLogger(t)
ctx := command.ProjectContext{
Log: logger,
Workspace: "workspace",
RepoRelDir: ".",
EscapedCommentArgs: []string{"comment", "args"},
}
Ok(t, err)

RegisterMockTestingT(t)
terraform := tfclientmocks.NewMockClient()
mockDownloader := mocks.NewMockDownloader()
tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader)
o := runtime.ApplyStepRunner{
TerraformExecutor: terraform,
DefaultTFDistribution: tfDistribution,
}

// Simulate TFE returning the exact error for no changes
When(terraform.RunCommandWithVersion(Any[command.ProjectContext](), Any[string](), Any[[]string](), Any[map[string]string](), Any[tf.Distribution](), Any[*version.Version](), Any[string]())).
ThenReturn("Error: Saved plan has no changes", errors.New("exit status 1"))
output, err := o.Run(ctx, []string{"extra", "args"}, tmpDir, map[string]string(nil))
// Should NOT return an error, even though terraform returned one
Ok(t, err)
// Output should indicate successful apply with 0 changes
Assert(t, strings.Contains(output, "Apply complete! Resources: 0 added, 0 changed, 0 destroyed"), "output should indicate successful apply")
terraform.VerifyWasCalledOnce().RunCommandWithVersion(ctx, tmpDir, []string{"apply", "-input=false", "extra", "args", "comment", "args", fmt.Sprintf("%q", planPath)}, map[string]string(nil), tfDistribution, nil, "workspace")
// Planfile should be deleted since apply was "successful"
_, err = os.Stat(planPath)
Assert(t, os.IsNotExist(err), "planfile should be deleted")
}

// Test that other errors are NOT treated as "no changes"
func TestRun_OtherNoChangesError_StillFails(t *testing.T) {
tmpDir := t.TempDir()
planPath := filepath.Join(tmpDir, "workspace.tfplan")
err := os.WriteFile(planPath, nil, 0600)
logger := logging.NewNoopLogger(t)
ctx := command.ProjectContext{
Log: logger,
Workspace: "workspace",
RepoRelDir: ".",
EscapedCommentArgs: []string{"comment", "args"},
}
Ok(t, err)

RegisterMockTestingT(t)
terraform := tfclientmocks.NewMockClient()
mockDownloader := mocks.NewMockDownloader()
tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader)
o := runtime.ApplyStepRunner{
TerraformExecutor: terraform,
DefaultTFDistribution: tfDistribution,
}

// Simulate a different error message that's not the exact TFE error
When(terraform.RunCommandWithVersion(Any[command.ProjectContext](), Any[string](), Any[[]string](), Any[map[string]string](), Any[tf.Distribution](), Any[*version.Version](), Any[string]())).
ThenReturn("Error: Plan has no changes to apply", errors.New("exit status 1"))
output, err := o.Run(ctx, []string{"extra", "args"}, tmpDir, map[string]string(nil))
// Should still return an error since it's not the exact message
Assert(t, err != nil, "other error messages should still fail")
Equals(t, "Error: Plan has no changes to apply", output)
// Planfile should NOT be deleted since apply failed
_, err = os.Stat(planPath)
Ok(t, err)
}

// Test that actual errors are still treated as errors
func TestRun_RealError_StillFails(t *testing.T) {
tmpDir := t.TempDir()
planPath := filepath.Join(tmpDir, "workspace.tfplan")
err := os.WriteFile(planPath, nil, 0600)
logger := logging.NewNoopLogger(t)
ctx := command.ProjectContext{
Log: logger,
Workspace: "workspace",
RepoRelDir: ".",
EscapedCommentArgs: []string{"comment", "args"},
}
Ok(t, err)

RegisterMockTestingT(t)
terraform := tfclientmocks.NewMockClient()
mockDownloader := mocks.NewMockDownloader()
tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader)
o := runtime.ApplyStepRunner{
TerraformExecutor: terraform,
DefaultTFDistribution: tfDistribution,
}

// Simulate a real error (not "no changes")
When(terraform.RunCommandWithVersion(Any[command.ProjectContext](), Any[string](), Any[[]string](), Any[map[string]string](), Any[tf.Distribution](), Any[*version.Version](), Any[string]())).
ThenReturn("Error: Failed to create resource", errors.New("exit status 1"))
output, err := o.Run(ctx, []string{"extra", "args"}, tmpDir, map[string]string(nil))
// Should still return an error
Assert(t, err != nil, "real errors should still fail")
Equals(t, "Error: Failed to create resource", output)
// Planfile should NOT be deleted since apply failed
_, err = os.Stat(planPath)
Ok(t, err)
}
9 changes: 9 additions & 0 deletions server/events/project_command_runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,15 @@ func (p *DefaultProjectCommandRunner) doApply(ctx command.ProjectContext) (apply
}
defer unlockFn()

// Skip apply if the plan had no changes. This prevents errors when using
// Terraform Cloud/Enterprise which fails with "Error: Saved plan has no changes"
// when applying a plan with no changes.
// See: https://github.com/runatlantis/atlantis/issues/4369
if ctx.ProjectPlanStatus == models.PlannedNoChangesPlanStatus {
ctx.Log.Info("plan had no changes, skipping apply")
return "No changes to apply. Infrastructure matches the plan.", "", nil
}

outputs, err := p.runSteps(ctx.Steps, ctx, absPath)

p.Webhooks.Send(ctx.Log, webhooks.ApplyResult{ // nolint: errcheck
Expand Down
64 changes: 64 additions & 0 deletions server/events/project_command_runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"errors"
"fmt"
"os"
"strings"
"testing"

"github.com/hashicorp/go-version"
Expand Down Expand Up @@ -543,6 +544,69 @@ func TestDefaultProjectCommandRunner_ApplyRunStepFailure(t *testing.T) {
mockApply.VerifyWasCalledOnce().Run(ctx, nil, repoDir, expEnvs)
}

// Test that apply is skipped when plan had no changes
func TestDefaultProjectCommandRunner_ApplySkipsNoChanges(t *testing.T) {
RegisterMockTestingT(t)
mockApply := mocks.NewMockStepRunner()
mockWorkingDir := mocks.NewMockWorkingDir()
mockLocker := mocks.NewMockProjectLocker()
mockSender := mocks.NewMockWebhooksSender()
applyReqHandler := &events.DefaultCommandRequirementHandler{
WorkingDir: mockWorkingDir,
}

runner := events.DefaultProjectCommandRunner{
Locker: mockLocker,
LockURLGenerator: mockURLGenerator{},
ApplyStepRunner: mockApply,
WorkingDir: mockWorkingDir,
WorkingDirLocker: events.NewDefaultWorkingDirLocker(),
CommandRequirementHandler: applyReqHandler,
Webhooks: mockSender,
}
repoDir := t.TempDir()
When(mockWorkingDir.GetWorkingDir(
Any[models.Repo](),
Any[models.PullRequest](),
Any[string](),
)).ThenReturn(repoDir, nil)
When(mockLocker.TryLock(
Any[logging.SimpleLogging](),
Any[models.PullRequest](),
Any[models.User](),
Any[string](),
Any[models.Project](),
AnyBool(),
)).ThenReturn(&events.TryLockResponse{
LockAcquired: true,
LockKey: "lock-key",
}, nil)

ctx := command.ProjectContext{
Log: logging.NewNoopLogger(t),
Steps: []valid.Step{
{
StepName: "apply",
},
},
Workspace: "default",
ApplyRequirements: []string{},
RepoRelDir: ".",
// This is the key: plan had no changes
ProjectPlanStatus: models.PlannedNoChangesPlanStatus,
}

res := runner.Apply(ctx)

// Should succeed with the skip message
Assert(t, res.ApplySuccess != "", "expected apply success message")
Assert(t, strings.Contains(res.ApplySuccess, "No changes to apply"), "expected no changes message")
Assert(t, res.Failure == "", "expected no failure")

// ApplyStepRunner should NOT have been called since we skipped it
mockApply.VerifyWasCalled(Never()).Run(Any[command.ProjectContext](), Any[[]string](), Any[string](), Any[map[string]string]())
}

// Test run and env steps. We don't use mocks for this test since we're
// not running any Terraform.
func TestDefaultProjectCommandRunner_RunEnvSteps(t *testing.T) {
Expand Down
Loading