This commit is contained in:
anupama-vijay 2026-05-25 05:04:22 +08:00 committed by GitHub
commit b9795a94b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 398 additions and 0 deletions

View file

@ -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
}

View file

@ -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)
}
}