diff --git a/.changes/v1.16/ENHANCEMENTS-20260403-170611.yaml b/.changes/v1.16/ENHANCEMENTS-20260403-170611.yaml new file mode 100644 index 000000000000..1791d2d0e167 --- /dev/null +++ b/.changes/v1.16/ENHANCEMENTS-20260403-170611.yaml @@ -0,0 +1,5 @@ +kind: ENHANCEMENTS +body: Print apply summary even on failure +time: 2026-04-03T17:06:11.38489-07:00 +custom: + Issue: "38343" diff --git a/internal/command/apply.go b/internal/command/apply.go index 29cfb7698e0f..8ab9a316d64d 100644 --- a/internal/command/apply.go +++ b/internal/command/apply.go @@ -126,19 +126,19 @@ func (c *ApplyCommand) Run(rawArgs []string) int { return 1 } - if op.Result != backendrun.OperationSuccess { - return op.Result.ExitStatus() - } - // Render the resource count and outputs, unless those counts are being // rendered already in a remote Terraform process. if rb, isRemoteBackend := be.(BackendWithRemoteTerraformVersion); !isRemoteBackend || rb.IsLocalOperations() { - view.ResourceCount(args.State.StateOutPath) - if !c.Destroy && op.State != nil { + view.ResourceCount(args.State.StateOutPath, op.Result != backendrun.OperationSuccess) + if op.Result == backendrun.OperationSuccess && !c.Destroy && op.State != nil { view.Outputs(op.State.RootOutputValues) } } + if op.Result != backendrun.OperationSuccess { + return op.Result.ExitStatus() + } + view.Diagnostics(diags) if diags.HasErrors() { diff --git a/internal/command/apply_test.go b/internal/command/apply_test.go index 6871327e4e07..5c0bee74886f 100644 --- a/internal/command/apply_test.go +++ b/internal/command/apply_test.go @@ -528,6 +528,78 @@ func TestApply_error(t *testing.T) { } } +func TestApply_jsonErrorIncludesChangeSummary(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath("apply-error"), td) + t.Chdir(td) + + statePath := testTempFile(t) + + p := testProvider() + view, done := testView(t) + c := &ApplyCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(p), + View: view, + }, + } + + var lock sync.Mutex + errored := false + p.ApplyResourceChangeFn = func(req providers.ApplyResourceChangeRequest) (resp providers.ApplyResourceChangeResponse) { + lock.Lock() + defer lock.Unlock() + + if !errored { + errored = true + resp.Diagnostics = resp.Diagnostics.Append(fmt.Errorf("error")) + } + + s := req.PlannedState.AsValueMap() + s["id"] = cty.StringVal("foo") + + resp.NewState = cty.ObjectVal(s) + return + } + p.PlanResourceChangeFn = func(req providers.PlanResourceChangeRequest) (resp providers.PlanResourceChangeResponse) { + s := req.ProposedNewState.AsValueMap() + s["id"] = cty.UnknownVal(cty.String) + resp.PlannedState = cty.ObjectVal(s) + return + } + p.GetProviderSchemaResponse = &providers.GetProviderSchemaResponse{ + ResourceTypes: map[string]providers.Schema{ + "test_instance": { + Body: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": {Type: cty.String, Optional: true, Computed: true}, + "ami": {Type: cty.String, Optional: true}, + "error": {Type: cty.Bool, Optional: true}, + }, + }, + }, + }, + } + + args := []string{ + "-json", + "-state", statePath, + "-auto-approve", + } + code := c.Run(args) + output := done(t) + if code != 1 { + t.Fatalf("wrong exit code %d; want 1\n%s", code, output.Stdout()) + } + + if got, want := output.Stdout(), `"type":"change_summary"`; !strings.Contains(got, want) { + t.Fatalf("missing change summary in JSON output:\n%s", got) + } + if got, want := output.Stdout(), `"@message":"Apply incomplete with errors! Resources: 1 added, 0 changed, 0 destroyed."`; !strings.Contains(got, want) { + t.Fatalf("missing partial apply summary in JSON output:\n%s", got) + } +} + func TestApply_input(t *testing.T) { // Create a temporary working directory that is empty td := t.TempDir() diff --git a/internal/command/views/apply.go b/internal/command/views/apply.go index 1ca7daefcdbd..fbfc0301f333 100644 --- a/internal/command/views/apply.go +++ b/internal/command/views/apply.go @@ -16,7 +16,7 @@ import ( // The Apply view is used for the apply command. type Apply interface { - ResourceCount(stateOutPath string) + ResourceCount(stateOutPath string, errored bool) Outputs(outputValues map[string]*states.OutputValue) Operation() Operation @@ -60,25 +60,33 @@ type ApplyHuman struct { var _ Apply = (*ApplyHuman)(nil) -func (v *ApplyHuman) ResourceCount(stateOutPath string) { +func (v *ApplyHuman) ResourceCount(stateOutPath string, errored bool) { var summary string + summaryColor := "[reset][bold][green]" + completionString := "complete" + if errored { + summaryColor = "[reset][bold][red]" + completionString = "incomplete with errors" + } if v.destroy { - summary = fmt.Sprintf("Destroy complete! Resources: %d destroyed.", v.countHook.Removed) + summary = fmt.Sprintf("Destroy %s! Resources: %d destroyed.", completionString, v.countHook.Removed) } else if v.countHook.Imported > 0 { - summary = fmt.Sprintf("Apply complete! Resources: %d imported, %d added, %d changed, %d destroyed.", + summary = fmt.Sprintf("Apply %s! Resources: %d imported, %d added, %d changed, %d destroyed.", + completionString, v.countHook.Imported, v.countHook.Added, v.countHook.Changed, v.countHook.Removed) } else { - summary = fmt.Sprintf("Apply complete! Resources: %d added, %d changed, %d destroyed.", + summary = fmt.Sprintf("Apply %s! Resources: %d added, %d changed, %d destroyed.", + completionString, v.countHook.Added, v.countHook.Changed, v.countHook.Removed) } - v.view.streams.Print(v.view.colorize.Color("[reset][bold][green]\n" + summary)) + v.view.streams.Print(v.view.colorize.Color(summaryColor + "\n" + summary)) if v.countHook.ActionInvocation > 0 { - v.view.streams.Print(v.view.colorize.Color(fmt.Sprintf("[reset][bold][green] Actions: %d invoked.", v.countHook.ActionInvocation))) + v.view.streams.Print(v.view.colorize.Color(fmt.Sprintf("%s Actions: %d invoked.", summaryColor, v.countHook.ActionInvocation))) } v.view.streams.Print("\n") if (v.countHook.Added > 0 || v.countHook.Changed > 0) && stateOutPath != "" { @@ -131,7 +139,7 @@ type ApplyJSON struct { var _ Apply = (*ApplyJSON)(nil) -func (v *ApplyJSON) ResourceCount(stateOutPath string) { +func (v *ApplyJSON) ResourceCount(stateOutPath string, errored bool) { operation := json.OperationApplied if v.destroy { operation = json.OperationDestroyed @@ -143,6 +151,7 @@ func (v *ApplyJSON) ResourceCount(stateOutPath string) { Import: v.countHook.Imported, ActionInvocation: v.countHook.ActionInvocation, Operation: operation, + Errored: errored, }) } diff --git a/internal/command/views/apply_test.go b/internal/command/views/apply_test.go index b9c9e7d312b6..99e04b3bb12a 100644 --- a/internal/command/views/apply_test.go +++ b/internal/command/views/apply_test.go @@ -153,7 +153,7 @@ func TestApply_resourceCount(t *testing.T) { count.Imported = 1 } - v.ResourceCount("") + v.ResourceCount("", false) got := done(t).Stdout() if !strings.Contains(got, tc.want) { @@ -222,7 +222,7 @@ func TestApplyHuman_resourceCountStatePath(t *testing.T) { count.Changed = tc.changed count.Removed = tc.removed - v.ResourceCount(tc.statePath) + v.ResourceCount(tc.statePath, false) got := done(t).Stdout() want := "State path: " + tc.statePath @@ -268,3 +268,61 @@ func TestApplyJSON_outputs(t *testing.T) { } testJSONViewOutputEquals(t, done(t).Stdout(), want) } + +func TestApplyHuman_resourceCountErrored(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + view := NewView(streams) + view.Configure(&arguments.View{NoColor: false}) + v := NewApply(arguments.ViewHuman, false, view) + hooks := v.Hooks() + + var count *countHook + for _, hook := range hooks { + if ch, ok := hook.(*countHook); ok { + count = ch + } + } + if count == nil { + t.Fatalf("expected Hooks to include a countHook: %#v", hooks) + } + + count.Added = 1 + count.Changed = 2 + count.Removed = 3 + + v.ResourceCount("", true) + + got := done(t).Stdout() + want := "\x1b[0m\x1b[1m\x1b[31m\nApply incomplete with errors! Resources: 1 added, 2 changed, 3 destroyed." + if !strings.Contains(got, want) { + t.Fatalf("wrong result\ngot: %#q\nwant to contain: %#q", got, want) + } +} + +func TestApplyJSON_resourceCountErrored(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + v := NewApply(arguments.ViewJSON, false, NewView(streams)) + hooks := v.Hooks() + + var count *countHook + for _, hook := range hooks { + if ch, ok := hook.(*countHook); ok { + count = ch + } + } + if count == nil { + t.Fatalf("expected Hooks to include a countHook: %#v", hooks) + } + + count.Added = 1 + + v.ResourceCount("", true) + + got := done(t).Stdout() + if !strings.Contains(got, `"@message":"Apply incomplete with errors! Resources: 1 added, 0 changed, 0 destroyed."`) { + t.Fatalf("wrong result\ngot: %q", got) + } + if !strings.Contains(got, `"errored":true`) { + t.Fatalf("expected errored field in json output\ngot: %q", got) + } +} diff --git a/internal/command/views/json/change_summary.go b/internal/command/views/json/change_summary.go index cd0f53a6d491..48a83d60c9b8 100644 --- a/internal/command/views/json/change_summary.go +++ b/internal/command/views/json/change_summary.go @@ -23,6 +23,7 @@ type ChangeSummary struct { Remove int `json:"remove"` ActionInvocation int `json:"action_invocation"` Operation Operation `json:"operation"` + Errored bool `json:"errored,omitempty"` } // The summary strings for apply and plan are accidentally a public interface @@ -32,7 +33,11 @@ func (cs *ChangeSummary) String() string { var buf strings.Builder switch cs.Operation { case OperationApplied: - buf.WriteString("Apply complete! Resources: ") + if cs.Errored { + buf.WriteString("Apply incomplete with errors! Resources: ") + } else { + buf.WriteString("Apply complete! Resources: ") + } if cs.Import > 0 { buf.WriteString(fmt.Sprintf("%d imported, ", cs.Import)) }