diff --git a/.github/workflows/atlantis-image.yml b/.github/workflows/atlantis-image.yml index 461738b1bb..7891df4dc7 100644 --- a/.github/workflows/atlantis-image.yml +++ b/.github/workflows/atlantis-image.yml @@ -50,7 +50,7 @@ jobs: id-token: write strategy: matrix: - image_type: [alpine, debian] + image_type: [debian] runs-on: ubuntu-24.04 env: # Determine target: staging for PRs, prod for main @@ -176,7 +176,7 @@ jobs: name: Build Image strategy: matrix: - image_type: [alpine, debian] + image_type: [debian] runs-on: ubuntu-24.04 steps: - run: 'echo "No build required"' diff --git a/VERSION b/VERSION index 6e8bf73aa5..0d91a54c7d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0 +0.3.0 diff --git a/server/core/runtime/apply_step_runner.go b/server/core/runtime/apply_step_runner.go index d884f73a37..e104e19a07 100644 --- a/server/core/runtime/apply_step_runner.go +++ b/server/core/runtime/apply_step_runner.go @@ -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") @@ -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" { diff --git a/server/core/runtime/apply_step_runner_test.go b/server/core/runtime/apply_step_runner_test.go index 62c37f21a7..ada6c187c0 100644 --- a/server/core/runtime/apply_step_runner_test.go +++ b/server/core/runtime/apply_step_runner_test.go @@ -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) +} diff --git a/server/events/project_command_runner.go b/server/events/project_command_runner.go index 48647622b0..36f4a39237 100644 --- a/server/events/project_command_runner.go +++ b/server/events/project_command_runner.go @@ -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 diff --git a/server/events/project_command_runner_test.go b/server/events/project_command_runner_test.go index 36bcbf90b7..45d5839b8c 100644 --- a/server/events/project_command_runner_test.go +++ b/server/events/project_command_runner_test.go @@ -17,6 +17,7 @@ import ( "errors" "fmt" "os" + "strings" "testing" "github.com/hashicorp/go-version" @@ -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) {