diff --git a/internal/cloud/backend_apply.go b/internal/cloud/backend_apply.go index e9b448346a..faa604a8bf 100644 --- a/internal/cloud/backend_apply.go +++ b/internal/cloud/backend_apply.go @@ -23,6 +23,10 @@ import ( func (b *Cloud) opApply(stopCtx, cancelCtx context.Context, op *backendrun.Operation, w *tfe.Workspace) (OperationResult, error) { log.Printf("[INFO] cloud: starting Apply operation") + // go-tfe doesn't currently expose a constant for the post-apply task stage. + // We still want to be able to look it up if the server returns it. + postApplyStage := tfe.Stage("post_apply") + var diags tfdiags.Diagnostics // We should remove the `CanUpdate` part of this test, but for now @@ -213,6 +217,39 @@ func (b *Cloud) opApply(stopCtx, cancelCtx context.Context, op *backendrun.Opera return &RunResult{run: r, backend: b}, err } + // If the apply failed, keep watching the run long enough to summarize any + // configured post-apply tasks before returning control back to Terraform. + // + // Note: We intentionally key off the apply status (not the run status), since + // Atlas may delay marking the overall run as errored until post-apply tasks + // have finished. + if r.Apply != nil { + apply, err := b.client.Applies.Read(stopCtx, r.Apply.ID) + if err != nil { + return &RunResult{run: r, backend: b}, b.generalError("Failed to retrieve apply", err) + } + r.Apply = apply + } + + if r.Apply != nil && r.Apply.Status == tfe.ApplyStatus("errored") { + // Refresh task stages after apply fails to get post-apply stage + taskStages, err = b.runTaskStages(stopCtx, b.client, r.ID) + if err != nil { + return &RunResult{run: r, backend: b}, err + } + + if stage, ok := taskStages[postApplyStage]; ok && stage != nil && stage.ID != "" { + if err := b.waitTaskStage(stopCtx, cancelCtx, op, r, stage.ID, "Post-apply Tasks"); err != nil { + return &RunResult{run: r, backend: b}, err + } + // Refresh the run so callers observe the final status after post-apply. + r, err = b.client.Runs.Read(stopCtx, r.ID) + if err != nil { + return &RunResult{run: r, backend: b}, b.generalError("Failed to retrieve run", err) + } + } + } + return &RunResult{run: r, backend: b}, nil } diff --git a/internal/cloud/backend_apply_test.go b/internal/cloud/backend_apply_test.go index 0c454c39cc..6ff4c29fab 100644 --- a/internal/cloud/backend_apply_test.go +++ b/internal/cloud/backend_apply_test.go @@ -2038,3 +2038,364 @@ Result: false FALSE - Passthrough.sentinel:1:1 - Rule "main" ` + + +// TestCloud_applyWithPostApplyTasksAfterApplyError tests that post-apply tasks +// are properly waited for and displayed when an apply operation fails. +func TestCloud_applyWithPostApplyTasksAfterApplyError(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + ctrl := gomock.NewController(t) + + // Mock the Applies API to return an errored apply + applyMock := mocks.NewMockApplies(ctrl) + logs := strings.NewReader("\n\n\nError: Apply failed") + applyMock.EXPECT().Logs(gomock.Any(), gomock.Any()).Return(logs, nil).AnyTimes() + applyMock.EXPECT().Read(gomock.Any(), gomock.Any()).Return(&tfe.Apply{ + ID: "apply-123", + Status: tfe.ApplyStatus("errored"), + }, nil).AnyTimes() + b.client.Applies = applyMock + + // Mock the Runs API to include post-apply task stage after refresh + runsMock := mocks.NewMockRuns(ctrl) + postApplyStage := tfe.Stage("post_apply") + + // Initial run without post-apply stage + initialRun := &tfe.Run{ + ID: "run-123", + Status: tfe.RunApplying, + HasChanges: true, + Apply: &tfe.Apply{ + ID: "apply-123", + Status: tfe.ApplyStatus("errored"), + }, + TaskStages: []*tfe.TaskStage{}, + } + + // Run with post-apply stage after refresh + runWithPostApply := &tfe.Run{ + ID: "run-123", + Status: tfe.RunErrored, + HasChanges: true, + Apply: &tfe.Apply{ + ID: "apply-123", + Status: tfe.ApplyStatus("errored"), + }, + TaskStages: []*tfe.TaskStage{ + { + ID: "ts-post-apply", + Stage: postApplyStage, + Status: tfe.TaskStageRunning, + }, + }, + } + + // Final run after post-apply completes + finalRun := &tfe.Run{ + ID: "run-123", + Status: tfe.RunErrored, + HasChanges: true, + Apply: &tfe.Apply{ + ID: "apply-123", + Status: tfe.ApplyStatus("errored"), + }, + TaskStages: []*tfe.TaskStage{ + { + ID: "ts-post-apply", + Stage: postApplyStage, + Status: tfe.TaskStagePassed, + }, + }, + } + + gomock.InOrder( + runsMock.EXPECT().ReadWithOptions(gomock.Any(), "run-123", gomock.Any()).Return(initialRun, nil), + runsMock.EXPECT().ReadWithOptions(gomock.Any(), "run-123", gomock.Any()).Return(runWithPostApply, nil), + runsMock.EXPECT().Read(gomock.Any(), "run-123").Return(finalRun, nil), + ) + b.client.Runs = runsMock + + // Mock TaskStages API + taskStagesMock := mocks.NewMockTaskStages(ctrl) + taskStagesMock.EXPECT().Read(gomock.Any(), "ts-post-apply", gomock.Any()).Return(&tfe.TaskStage{ + ID: "ts-post-apply", + Stage: postApplyStage, + Status: tfe.TaskStagePassed, + }, nil).AnyTimes() + b.client.TaskStages = taskStagesMock + + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + defer done(t) + + input := testInput(t, map[string]string{ + "approve": "yes", + }) + + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = testBackendSingleWorkspaceName + op.AutoApprove = false + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + + // The operation should fail due to apply error, but post-apply tasks should have been processed + if run.Result == backendrun.OperationSuccess { + t.Fatal("expected apply operation to fail due to errored apply") + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Post-apply Tasks") { + t.Fatalf("expected post-apply tasks header in output: %s", output) + } +} + +// TestCloud_applyWithPostApplyTasksAfterApplyErrorNoStage tests that when +// an apply fails but there are no post-apply tasks, the operation completes normally. +func TestCloud_applyWithPostApplyTasksAfterApplyErrorNoStage(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + ctrl := gomock.NewController(t) + + // Mock the Applies API to return an errored apply + applyMock := mocks.NewMockApplies(ctrl) + logs := strings.NewReader("\n\n\nError: Apply failed") + applyMock.EXPECT().Logs(gomock.Any(), gomock.Any()).Return(logs, nil).AnyTimes() + applyMock.EXPECT().Read(gomock.Any(), gomock.Any()).Return(&tfe.Apply{ + ID: "apply-123", + Status: tfe.ApplyStatus("errored"), + }, nil).AnyTimes() + b.client.Applies = applyMock + + // Mock the Runs API without post-apply task stage + runsMock := mocks.NewMockRuns(ctrl) + + runWithoutPostApply := &tfe.Run{ + ID: "run-123", + Status: tfe.RunErrored, + HasChanges: true, + Apply: &tfe.Apply{ + ID: "apply-123", + Status: tfe.ApplyStatus("errored"), + }, + TaskStages: []*tfe.TaskStage{}, + } + + runsMock.EXPECT().ReadWithOptions(gomock.Any(), "run-123", gomock.Any()).Return(runWithoutPostApply, nil).AnyTimes() + b.client.Runs = runsMock + + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + defer done(t) + + input := testInput(t, map[string]string{ + "approve": "yes", + }) + + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = testBackendSingleWorkspaceName + op.AutoApprove = false + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + + // The operation should fail due to apply error + if run.Result == backendrun.OperationSuccess { + t.Fatal("expected apply operation to fail due to errored apply") + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + // Should not contain post-apply tasks header since there are no post-apply tasks + if strings.Contains(output, "Post-apply Tasks") { + t.Fatalf("unexpected post-apply tasks header in output when no post-apply stage exists: %s", output) + } +} + +// TestCloud_applyWithPostApplyTasksAfterApplyErrorEmptyStageID tests that when +// an apply fails and post-apply stage exists but has empty ID, it's skipped. +func TestCloud_applyWithPostApplyTasksAfterApplyErrorEmptyStageID(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + ctrl := gomock.NewController(t) + + // Mock the Applies API to return an errored apply + applyMock := mocks.NewMockApplies(ctrl) + logs := strings.NewReader("\n\n\nError: Apply failed") + applyMock.EXPECT().Logs(gomock.Any(), gomock.Any()).Return(logs, nil).AnyTimes() + applyMock.EXPECT().Read(gomock.Any(), gomock.Any()).Return(&tfe.Apply{ + ID: "apply-123", + Status: tfe.ApplyStatus("errored"), + }, nil).AnyTimes() + b.client.Applies = applyMock + + // Mock the Runs API with post-apply task stage but empty ID + runsMock := mocks.NewMockRuns(ctrl) + postApplyStage := tfe.Stage("post_apply") + + runWithEmptyStageID := &tfe.Run{ + ID: "run-123", + Status: tfe.RunErrored, + HasChanges: true, + Apply: &tfe.Apply{ + ID: "apply-123", + Status: tfe.ApplyStatus("errored"), + }, + TaskStages: []*tfe.TaskStage{ + { + ID: "", // Empty ID should be skipped + Stage: postApplyStage, + }, + }, + } + + runsMock.EXPECT().ReadWithOptions(gomock.Any(), "run-123", gomock.Any()).Return(runWithEmptyStageID, nil).AnyTimes() + b.client.Runs = runsMock + + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + defer done(t) + + input := testInput(t, map[string]string{ + "approve": "yes", + }) + + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = testBackendSingleWorkspaceName + op.AutoApprove = false + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + + // The operation should fail due to apply error + if run.Result == backendrun.OperationSuccess { + t.Fatal("expected apply operation to fail due to errored apply") + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + // Should not contain post-apply tasks header since stage ID is empty + if strings.Contains(output, "Post-apply Tasks") { + t.Fatalf("unexpected post-apply tasks header in output when stage ID is empty: %s", output) + } +} + +// TestCloud_applySuccessNoPostApplyTasks tests that successful applies +// without post-apply tasks work as expected (no regression). +func TestCloud_applySuccessNoPostApplyTasks(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + defer done(t) + + input := testInput(t, map[string]string{ + "approve": "yes", + }) + + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backendrun.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + // Successful apply should not trigger post-apply task stage logic + if strings.Contains(output, "Post-apply Tasks") { + t.Fatalf("unexpected post-apply tasks header in successful apply output: %s", output) + } +} + +// TestCloud_applyWithPostApplyTasksRefreshError tests error handling when +// refreshing task stages after apply error fails. +func TestCloud_applyWithPostApplyTasksRefreshError(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + ctrl := gomock.NewController(t) + + // Mock the Applies API to return an errored apply + applyMock := mocks.NewMockApplies(ctrl) + logs := strings.NewReader("\n\n\nError: Apply failed") + applyMock.EXPECT().Logs(gomock.Any(), gomock.Any()).Return(logs, nil).AnyTimes() + applyMock.EXPECT().Read(gomock.Any(), gomock.Any()).Return(&tfe.Apply{ + ID: "apply-123", + Status: tfe.ApplyStatus("errored"), + }, nil).AnyTimes() + b.client.Applies = applyMock + + // Mock the Runs API to fail on second ReadWithOptions (task stage refresh) + runsMock := mocks.NewMockRuns(ctrl) + + initialRun := &tfe.Run{ + ID: "run-123", + Status: tfe.RunApplying, + HasChanges: true, + Apply: &tfe.Apply{ + ID: "apply-123", + Status: tfe.ApplyStatus("errored"), + }, + TaskStages: []*tfe.TaskStage{}, + } + + gomock.InOrder( + runsMock.EXPECT().ReadWithOptions(gomock.Any(), "run-123", gomock.Any()).Return(initialRun, nil), + runsMock.EXPECT().ReadWithOptions(gomock.Any(), "run-123", gomock.Any()).Return(nil, fmt.Errorf("failed to refresh task stages")), + ) + b.client.Runs = runsMock + + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + defer done(t) + + input := testInput(t, map[string]string{ + "approve": "yes", + }) + + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = testBackendSingleWorkspaceName + op.AutoApprove = false + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + + // The operation should fail + if run.Result == backendrun.OperationSuccess { + t.Fatal("expected apply operation to fail") + } + + errOutput := b.CLI.(*cli.MockUi).ErrorWriter.String() + if !strings.Contains(errOutput, "failed to refresh task stages") { + t.Fatalf("expected error about task stage refresh failure: %s", errOutput) + } +}