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
5 changes: 5 additions & 0 deletions .changes/v1.16/ENHANCEMENTS-20260403-170611.yaml
Original file line number Diff line number Diff line change
@@ -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"
12 changes: 6 additions & 6 deletions internal/command/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
72 changes: 72 additions & 0 deletions internal/command/apply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
25 changes: 17 additions & 8 deletions internal/command/views/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 != "" {
Expand Down Expand Up @@ -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
Expand All @@ -143,6 +151,7 @@ func (v *ApplyJSON) ResourceCount(stateOutPath string) {
Import: v.countHook.Imported,
ActionInvocation: v.countHook.ActionInvocation,
Operation: operation,
Errored: errored,
})
}

Expand Down
62 changes: 60 additions & 2 deletions internal/command/views/apply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
7 changes: 6 additions & 1 deletion internal/command/views/json/change_summary.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))
}
Expand Down
Loading