From f72730a02be25ce0b56eb3acc04408060191b217 Mon Sep 17 00:00:00 2001 From: Alisdair McDiarmid Date: Tue, 23 Feb 2021 10:16:09 -0500 Subject: [PATCH] cli: Add JSON logs for operations commands --- backend/local/backend_apply.go | 22 ++ command/apply_test.go | 116 ++++++++ command/arguments/apply.go | 21 ++ command/arguments/apply_test.go | 56 ++++ command/arguments/plan.go | 10 + command/arguments/plan_test.go | 16 ++ command/arguments/refresh.go | 10 + command/arguments/refresh_test.go | 9 +- command/arguments/types.go | 15 ++ command/testdata/apply/output.jsonlog | 6 + command/views/apply.go | 59 +++++ command/views/apply_test.go | 89 +++++-- command/views/hook_json.go | 156 +++++++++++ command/views/hook_json_test.go | 325 +++++++++++++++++++++++ command/views/json/change.go | 55 ++++ command/views/json/change_summary.go | 34 +++ command/views/json/hook.go | 364 ++++++++++++++++++++++++++ command/views/json/message_types.go | 27 ++ command/views/json/output.go | 55 ++++ command/views/json/output_test.go | 87 ++++++ command/views/json/resource_addr.go | 34 +++ command/views/json_view.go | 120 +++++++++ command/views/json_view_test.go | 307 ++++++++++++++++++++++ command/views/operation.go | 121 ++++++++- command/views/operation_test.go | 318 ++++++++++++++++++++++ command/views/plan.go | 29 ++ command/views/refresh.go | 39 +++ command/views/refresh_test.go | 34 +++ 28 files changed, 2499 insertions(+), 35 deletions(-) create mode 100644 command/testdata/apply/output.jsonlog create mode 100644 command/views/hook_json.go create mode 100644 command/views/hook_json_test.go create mode 100644 command/views/json/change.go create mode 100644 command/views/json/change_summary.go create mode 100644 command/views/json/hook.go create mode 100644 command/views/json/message_types.go create mode 100644 command/views/json/output.go create mode 100644 command/views/json/output_test.go create mode 100644 command/views/json/resource_addr.go create mode 100644 command/views/json_view.go create mode 100644 command/views/json_view_test.go diff --git a/backend/local/backend_apply.go b/backend/local/backend_apply.go index 95bf07c79..ce77b12b7 100644 --- a/backend/local/backend_apply.go +++ b/backend/local/backend_apply.go @@ -121,6 +121,28 @@ func (b *Local) opApply( runningOp.Result = backend.OperationFailure return } + } else { + for _, change := range plan.Changes.Resources { + if change.Action != plans.NoOp { + op.View.PlannedChange(change) + } + } + } + } else { + plan, err := op.PlanFile.ReadPlan() + if err != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Invalid plan file", + fmt.Sprintf("Failed to read plan from plan file: %s.", err), + )) + op.ReportResult(runningOp, diags) + return + } + for _, change := range plan.Changes.Resources { + if change.Action != plans.NoOp { + op.View.PlannedChange(change) + } } } diff --git a/command/apply_test.go b/command/apply_test.go index 6432bffb3..63be339f3 100644 --- a/command/apply_test.go +++ b/command/apply_test.go @@ -3,9 +3,11 @@ package command import ( "bytes" "context" + "encoding/json" "fmt" "io/ioutil" "os" + "path" "path/filepath" "reflect" "strings" @@ -19,12 +21,14 @@ import ( "github.com/zclconf/go-cty/cty" "github.com/hashicorp/terraform/addrs" + "github.com/hashicorp/terraform/command/views" "github.com/hashicorp/terraform/configs/configschema" "github.com/hashicorp/terraform/plans" "github.com/hashicorp/terraform/providers" "github.com/hashicorp/terraform/states" "github.com/hashicorp/terraform/states/statemgr" "github.com/hashicorp/terraform/terraform" + tfversion "github.com/hashicorp/terraform/version" ) func TestApply(t *testing.T) { @@ -1899,6 +1903,118 @@ func TestApply_pluginPath(t *testing.T) { } } +func TestApply_jsonGoldenReference(t *testing.T) { + // Create a temporary working directory that is empty + td := tempDir(t) + testCopyDir(t, testFixturePath("apply"), td) + defer os.RemoveAll(td) + defer testChdir(t, td)() + + statePath := testTempFile(t) + + p := applyFixtureProvider() + + view, done := testView(t) + c := &ApplyCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(p), + View: view, + }, + } + + args := []string{ + "-json", + "-state", statePath, + "-auto-approve", + } + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) + } + + if _, err := os.Stat(statePath); err != nil { + t.Fatalf("err: %s", err) + } + + state := testStateRead(t, statePath) + if state == nil { + t.Fatal("state should not be nil") + } + + // Load the golden reference fixture + wantFile, err := os.Open(path.Join(testFixturePath("apply"), "output.jsonlog")) + if err != nil { + t.Fatalf("failed to open output file: %s", err) + } + defer wantFile.Close() + wantBytes, err := ioutil.ReadAll(wantFile) + if err != nil { + t.Fatalf("failed to read output file: %s", err) + } + want := string(wantBytes) + + got := output.Stdout() + + // Split the output and the reference into lines so that we can compare + // messages + got = strings.TrimSuffix(got, "\n") + gotLines := strings.Split(got, "\n") + + want = strings.TrimSuffix(want, "\n") + wantLines := strings.Split(want, "\n") + + if len(gotLines) != len(wantLines) { + t.Fatalf("unexpected number of log lines: got %d, want %d", len(gotLines), len(wantLines)) + } + + // Verify that the log starts with a version message + type versionMessage struct { + Level string `json:"@level"` + Message string `json:"@message"` + Type string `json:"type"` + Terraform string `json:"terraform"` + UI string `json:"ui"` + } + var gotVersion versionMessage + if err := json.Unmarshal([]byte(gotLines[0]), &gotVersion); err != nil { + t.Errorf("failed to unmarshal version line: %s\n%s", err, gotLines[0]) + } + wantVersion := versionMessage{ + "info", + fmt.Sprintf("Terraform %s", tfversion.String()), + "version", + tfversion.String(), + views.JSON_UI_VERSION, + } + if !cmp.Equal(wantVersion, gotVersion) { + t.Errorf("unexpected first message:\n%s", cmp.Diff(wantVersion, gotVersion)) + } + + // Compare the rest of the lines against the golden reference + for i := range gotLines[1:] { + index := i + 1 + var gotMap, wantMap map[string]interface{} + if err := json.Unmarshal([]byte(gotLines[index]), &gotMap); err != nil { + t.Errorf("failed to unmarshal got line %d: %s\n%s", index, err, gotLines[i]) + } + if err := json.Unmarshal([]byte(wantLines[index]), &wantMap); err != nil { + t.Errorf("failed to unmarshal want line %d: %s\n%s", index, err, wantLines[i]) + } + + // The timestamp field is the only one that should change, so we drop + // it from the comparison + if _, ok := gotMap["@timestamp"]; !ok { + t.Errorf("missing @timestamp field in log: %s", gotLines[i]) + } + delete(gotMap, "@timestamp") + + if !cmp.Equal(wantMap, gotMap) { + t.Errorf("unexpected log:\n%s", cmp.Diff(wantMap, gotMap)) + } + } +} + // applyFixtureSchema returns a schema suitable for processing the // configuration in testdata/apply . This schema should be // assigned to a mock provider named "test". diff --git a/command/arguments/apply.go b/command/arguments/apply.go index 8b7b0d674..267c8e539 100644 --- a/command/arguments/apply.go +++ b/command/arguments/apply.go @@ -43,6 +43,9 @@ func ParseApply(args []string) (*Apply, tfdiags.Diagnostics) { cmdFlags.BoolVar(&apply.AutoApprove, "auto-approve", false, "auto-approve") cmdFlags.BoolVar(&apply.InputEnabled, "input", true, "input") + var json bool + cmdFlags.BoolVar(&json, "json", false, "json") + if err := cmdFlags.Parse(args); err != nil { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, @@ -65,9 +68,27 @@ func ParseApply(args []string) (*Apply, tfdiags.Diagnostics) { )) } + // JSON view currently does not support input, so we disable it here. + if json { + apply.InputEnabled = false + } + + // JSON view cannot confirm apply, so we require either a plan file or + // auto-approve to be specified. We intentionally fail here rather than + // override auto-approve, which would be dangerous. + if json && apply.PlanPath == "" && !apply.AutoApprove { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Plan file or auto-approve required", + "Terraform cannot ask for interactive approval when -json is set. You can either apply a saved plan file, or enable the -auto-approve option.", + )) + } + diags = diags.Append(apply.Operation.Parse()) switch { + case json: + apply.ViewType = ViewJSON default: apply.ViewType = ViewHuman } diff --git a/command/arguments/apply_test.go b/command/arguments/apply_test.go index c22521cba..b0134c469 100644 --- a/command/arguments/apply_test.go +++ b/command/arguments/apply_test.go @@ -63,6 +63,22 @@ func TestParseApply_basicValid(t *testing.T) { }, }, }, + "JSON view disables input": { + []string{"-json", "-auto-approve"}, + &Apply{ + AutoApprove: true, + InputEnabled: false, + PlanPath: "", + ViewType: ViewJSON, + State: &State{Lock: true}, + Vars: &Vars{}, + Operation: &Operation{ + PlanMode: plans.NormalMode, + Parallelism: 10, + Refresh: true, + }, + }, + }, } cmpOpts := cmpopts.IgnoreUnexported(Operation{}, Vars{}, State{}) @@ -80,6 +96,46 @@ func TestParseApply_basicValid(t *testing.T) { } } +func TestParseApply_json(t *testing.T) { + testCases := map[string]struct { + args []string + wantSuccess bool + }{ + "-json": { + []string{"-json"}, + false, + }, + "-json -auto-approve": { + []string{"-json", "-auto-approve"}, + true, + }, + "-json saved.tfplan": { + []string{"-json", "saved.tfplan"}, + true, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseApply(tc.args) + + if tc.wantSuccess { + if len(diags) > 0 { + t.Errorf("unexpected diags: %v", diags) + } + } else { + if got, want := diags.Err().Error(), "Plan file or auto-approve required"; !strings.Contains(got, want) { + t.Errorf("wrong diags\n got: %s\nwant: %s", got, want) + } + } + + if got.ViewType != ViewJSON { + t.Errorf("unexpected view type. got: %#v, want: %#v", got.ViewType, ViewJSON) + } + }) + } +} + func TestParseApply_invalid(t *testing.T) { got, diags := ParseApply([]string{"-frob"}) if len(diags) == 0 { diff --git a/command/arguments/plan.go b/command/arguments/plan.go index 1c7be20fc..0a9375944 100644 --- a/command/arguments/plan.go +++ b/command/arguments/plan.go @@ -42,6 +42,9 @@ func ParsePlan(args []string) (*Plan, tfdiags.Diagnostics) { cmdFlags.BoolVar(&plan.InputEnabled, "input", true, "input") cmdFlags.StringVar(&plan.OutPath, "out", "", "out") + var json bool + cmdFlags.BoolVar(&json, "json", false, "json") + if err := cmdFlags.Parse(args); err != nil { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, @@ -62,7 +65,14 @@ func ParsePlan(args []string) (*Plan, tfdiags.Diagnostics) { diags = diags.Append(plan.Operation.Parse()) + // JSON view currently does not support input, so we disable it here + if json { + plan.InputEnabled = false + } + switch { + case json: + plan.ViewType = ViewJSON default: plan.ViewType = ViewHuman } diff --git a/command/arguments/plan_test.go b/command/arguments/plan_test.go index bf336b00d..14c72e9fb 100644 --- a/command/arguments/plan_test.go +++ b/command/arguments/plan_test.go @@ -47,6 +47,22 @@ func TestParsePlan_basicValid(t *testing.T) { }, }, }, + "JSON view disables input": { + []string{"-json"}, + &Plan{ + DetailedExitCode: false, + InputEnabled: false, + OutPath: "", + ViewType: ViewJSON, + State: &State{Lock: true}, + Vars: &Vars{}, + Operation: &Operation{ + PlanMode: plans.NormalMode, + Parallelism: 10, + Refresh: true, + }, + }, + }, } cmpOpts := cmpopts.IgnoreUnexported(Operation{}, Vars{}, State{}) diff --git a/command/arguments/refresh.go b/command/arguments/refresh.go index 56336e3a6..0e35483aa 100644 --- a/command/arguments/refresh.go +++ b/command/arguments/refresh.go @@ -33,6 +33,9 @@ func ParseRefresh(args []string) (*Refresh, tfdiags.Diagnostics) { cmdFlags := extendedFlagSet("refresh", refresh.State, refresh.Operation, refresh.Vars) cmdFlags.BoolVar(&refresh.InputEnabled, "input", true, "input") + var json bool + cmdFlags.BoolVar(&json, "json", false, "json") + if err := cmdFlags.Parse(args); err != nil { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, @@ -52,7 +55,14 @@ func ParseRefresh(args []string) (*Refresh, tfdiags.Diagnostics) { diags = diags.Append(refresh.Operation.Parse()) + // JSON view currently does not support input, so we disable it here + if json { + refresh.InputEnabled = false + } + switch { + case json: + refresh.ViewType = ViewJSON default: refresh.ViewType = ViewHuman } diff --git a/command/arguments/refresh_test.go b/command/arguments/refresh_test.go index 0f7676417..6988b77f5 100644 --- a/command/arguments/refresh_test.go +++ b/command/arguments/refresh_test.go @@ -20,13 +20,20 @@ func TestParseRefresh_basicValid(t *testing.T) { ViewType: ViewHuman, }, }, - "input=flase": { + "input=false": { []string{"-input=false"}, &Refresh{ InputEnabled: false, ViewType: ViewHuman, }, }, + "JSON view disables input": { + []string{"-json"}, + &Refresh{ + InputEnabled: false, + ViewType: ViewJSON, + }, + }, } for name, tc := range testCases { diff --git a/command/arguments/types.go b/command/arguments/types.go index 0203a04eb..ff529361e 100644 --- a/command/arguments/types.go +++ b/command/arguments/types.go @@ -11,3 +11,18 @@ const ( ViewJSON ViewType = 'J' ViewRaw ViewType = 'R' ) + +func (vt ViewType) String() string { + switch vt { + case ViewNone: + return "none" + case ViewHuman: + return "human" + case ViewJSON: + return "json" + case ViewRaw: + return "raw" + default: + return "unknown" + } +} diff --git a/command/testdata/apply/output.jsonlog b/command/testdata/apply/output.jsonlog new file mode 100644 index 000000000..a70603491 --- /dev/null +++ b/command/testdata/apply/output.jsonlog @@ -0,0 +1,6 @@ +{"@level":"info","@message":"Terraform 0.15.0-dev","@module":"terraform.ui","terraform":"0.15.0-dev","type":"version","ui":"0.1.0"} +{"@level":"info","@message":"test_instance.foo: Plan to create","@module":"terraform.ui","change":{"resource":{"addr":"test_instance.foo","module":"","resource":"test_instance.foo","implied_provider":"test","resource_type":"test_instance","resource_name":"foo","resource_key":null},"action":"create"},"type":"planned_change"} +{"@level":"info","@message":"test_instance.foo: Creating...","@module":"terraform.ui","hook":{"resource":{"addr":"test_instance.foo","module":"","resource":"test_instance.foo","implied_provider":"test","resource_type":"test_instance","resource_name":"foo","resource_key":null},"action":"create"},"type":"apply_start"} +{"@level":"info","@message":"test_instance.foo: Creation complete after 0s","@module":"terraform.ui","hook":{"resource":{"addr":"test_instance.foo","module":"","resource":"test_instance.foo","implied_provider":"test","resource_type":"test_instance","resource_name":"foo","resource_key":null},"action":"create","elapsed_seconds":0},"type":"apply_complete"} +{"@level":"info","@message":"Apply complete! Resources: 1 added, 0 changed, 0 destroyed.","@module":"terraform.ui","changes":{"add":1,"change":0,"remove":0,"operation":"apply"},"type":"change_summary"} +{"@level":"info","@message":"Outputs: 0","@module":"terraform.ui","outputs":{},"type":"outputs"} diff --git a/command/views/apply.go b/command/views/apply.go index 40756601d..4f2b3bba5 100644 --- a/command/views/apply.go +++ b/command/views/apply.go @@ -5,6 +5,7 @@ import ( "github.com/hashicorp/terraform/command/arguments" "github.com/hashicorp/terraform/command/format" + "github.com/hashicorp/terraform/command/views/json" "github.com/hashicorp/terraform/states" "github.com/hashicorp/terraform/terraform" "github.com/hashicorp/terraform/tfdiags" @@ -25,6 +26,12 @@ type Apply interface { // NewApply returns an initialized Apply implementation for the given ViewType. func NewApply(vt arguments.ViewType, destroy bool, runningInAutomation bool, view *View) Apply { switch vt { + case arguments.ViewJSON: + return &ApplyJSON{ + view: NewJSONView(view), + destroy: destroy, + countHook: &countHook{}, + } case arguments.ViewHuman: return &ApplyHuman{ view: view, @@ -101,3 +108,55 @@ func (v *ApplyHuman) HelpPrompt() { } const stateOutPathPostApply = "The state of your infrastructure has been saved to the path below. This state is required to modify and destroy your infrastructure, so keep it safe. To inspect the complete state use the `terraform show` command." + +// The ApplyJSON implementation renders streaming JSON logs, suitable for +// integrating with other software. +type ApplyJSON struct { + view *JSONView + + destroy bool + + countHook *countHook +} + +var _ Apply = (*ApplyJSON)(nil) + +func (v *ApplyJSON) ResourceCount(stateOutPath string) { + operation := json.OperationApplied + if v.destroy { + operation = json.OperationDestroyed + } + v.view.ChangeSummary(&json.ChangeSummary{ + Add: v.countHook.Added, + Change: v.countHook.Changed, + Remove: v.countHook.Removed, + Operation: operation, + }) +} + +func (v *ApplyJSON) Outputs(outputValues map[string]*states.OutputValue) { + outputs, diags := json.OutputsFromMap(outputValues) + if diags.HasErrors() { + v.Diagnostics(diags) + } else { + v.view.Outputs(outputs) + } +} + +func (v *ApplyJSON) Operation() Operation { + return &OperationJSON{view: v.view} +} + +func (v *ApplyJSON) Hooks() []terraform.Hook { + return []terraform.Hook{ + v.countHook, + newJSONHook(v.view), + } +} + +func (v *ApplyJSON) Diagnostics(diags tfdiags.Diagnostics) { + v.view.Diagnostics(diags) +} + +func (v *ApplyJSON) HelpPrompt() { +} diff --git a/command/views/apply_test.go b/command/views/apply_test.go index 5d6f84e78..3d4ccee6e 100644 --- a/command/views/apply_test.go +++ b/command/views/apply_test.go @@ -1,6 +1,7 @@ package views import ( + "fmt" "strings" "testing" @@ -96,7 +97,7 @@ func TestApplyHuman_help(t *testing.T) { } // Hooks and ResourceCount are tangled up and easiest to test together. -func TestApplyHuman_resourceCount(t *testing.T) { +func TestApply_resourceCount(t *testing.T) { testCases := map[string]struct { destroy bool want string @@ -111,33 +112,39 @@ func TestApplyHuman_resourceCount(t *testing.T) { }, } + // For compatibility reasons, these tests should hold true for both human + // and JSON output modes + views := []arguments.ViewType{arguments.ViewHuman, arguments.ViewJSON} + for name, tc := range testCases { - t.Run(name, func(t *testing.T) { - streams, done := terminal.StreamsForTesting(t) - v := NewApply(arguments.ViewHuman, tc.destroy, false, NewView(streams)) - hooks := v.Hooks() + for _, viewType := range views { + t.Run(fmt.Sprintf("%s (%s view)", name, viewType), func(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + v := NewApply(viewType, tc.destroy, false, NewView(streams)) + hooks := v.Hooks() - var count *countHook - for _, hook := range hooks { - if ch, ok := hook.(*countHook); ok { - count = ch + 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) } - } - if count == nil { - t.Fatalf("expected Hooks to include a countHook: %#v", hooks) - } - count.Added = 1 - count.Changed = 2 - count.Removed = 3 + count.Added = 1 + count.Changed = 2 + count.Removed = 3 - v.ResourceCount("") + v.ResourceCount("") - got := done(t).Stdout() - if !strings.Contains(got, tc.want) { - t.Errorf("wrong result\ngot: %q\nwant: %q", got, tc.want) - } - }) + got := done(t).Stdout() + if !strings.Contains(got, tc.want) { + t.Errorf("wrong result\ngot: %q\nwant: %q", got, tc.want) + } + }) + } } } @@ -171,8 +178,8 @@ func TestApplyHuman_resourceCountStatePath(t *testing.T) { wantContains: true, }, "changed": { - added: 5, - changed: 0, + added: 0, + changed: 5, removed: 0, statePath: "foo.tfstate", wantContains: true, @@ -212,3 +219,37 @@ func TestApplyHuman_resourceCountStatePath(t *testing.T) { }) } } + +// Basic test coverage of Outputs, since most of its functionality is tested +// elsewhere. +func TestApplyJSON_outputs(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + v := NewApply(arguments.ViewJSON, false, false, NewView(streams)) + + v.Outputs(map[string]*states.OutputValue{ + "boop_count": {Value: cty.NumberIntVal(92)}, + "password": {Value: cty.StringVal("horse-battery").Mark("sensitive"), Sensitive: true}, + }) + + want := []map[string]interface{}{ + { + "@level": "info", + "@message": "Outputs: 2", + "@module": "terraform.ui", + "type": "outputs", + "outputs": map[string]interface{}{ + "boop_count": map[string]interface{}{ + "sensitive": false, + "value": float64(92), + "type": "number", + }, + "password": map[string]interface{}{ + "sensitive": true, + "value": "horse-battery", + "type": "string", + }, + }, + }, + } + testJSONViewOutputEquals(t, done(t).Stdout(), want) +} diff --git a/command/views/hook_json.go b/command/views/hook_json.go new file mode 100644 index 000000000..6dd27a64a --- /dev/null +++ b/command/views/hook_json.go @@ -0,0 +1,156 @@ +package views + +import ( + "bufio" + "strings" + "sync" + "time" + "unicode" + + "github.com/hashicorp/terraform/addrs" + "github.com/hashicorp/terraform/command/format" + "github.com/hashicorp/terraform/command/views/json" + "github.com/hashicorp/terraform/plans" + "github.com/hashicorp/terraform/states" + "github.com/hashicorp/terraform/terraform" + "github.com/zclconf/go-cty/cty" +) + +// How long to wait between sending heartbeat/progress messages +const heartbeatInterval = 10 * time.Second + +func newJSONHook(view *JSONView) *jsonHook { + return &jsonHook{ + view: view, + applying: make(map[string]applyProgress), + timeNow: time.Now, + timeAfter: time.After, + } +} + +type jsonHook struct { + terraform.NilHook + + view *JSONView + + // Concurrent map of resource addresses to allow the sequence of pre-apply, + // progress, and post-apply messages to share data about the resource + applying map[string]applyProgress + applyingLock sync.Mutex + + // Mockable functions for testing the progress timer goroutine + timeNow func() time.Time + timeAfter func(time.Duration) <-chan time.Time +} + +var _ terraform.Hook = (*jsonHook)(nil) + +type applyProgress struct { + addr addrs.AbsResourceInstance + action plans.Action + start time.Time + + // done is used for post-apply to stop the progress goroutine + done chan struct{} + + // heartbeatDone is used to allow tests to safely wait for the progress + // goroutine to finish + heartbeatDone chan struct{} +} + +func (h *jsonHook) PreApply(addr addrs.AbsResourceInstance, gen states.Generation, action plans.Action, priorState, plannedNewState cty.Value) (terraform.HookAction, error) { + idKey, idValue := format.ObjectValueIDOrName(priorState) + h.view.Hook(json.NewApplyStart(addr, action, idKey, idValue)) + + progress := applyProgress{ + addr: addr, + action: action, + start: h.timeNow().Round(time.Second), + done: make(chan struct{}), + heartbeatDone: make(chan struct{}), + } + h.applyingLock.Lock() + h.applying[addr.String()] = progress + h.applyingLock.Unlock() + + go h.applyingHeartbeat(progress) + return terraform.HookActionContinue, nil +} + +func (h *jsonHook) applyingHeartbeat(progress applyProgress) { + defer close(progress.heartbeatDone) + for { + select { + case <-progress.done: + return + case <-h.timeAfter(heartbeatInterval): + } + + elapsed := h.timeNow().Round(time.Second).Sub(progress.start) + h.view.Hook(json.NewApplyProgress(progress.addr, progress.action, elapsed)) + } +} + +func (h *jsonHook) PostApply(addr addrs.AbsResourceInstance, gen states.Generation, newState cty.Value, err error) (terraform.HookAction, error) { + key := addr.String() + h.applyingLock.Lock() + progress := h.applying[key] + if progress.done != nil { + close(progress.done) + } + delete(h.applying, key) + h.applyingLock.Unlock() + + elapsed := h.timeNow().Round(time.Second).Sub(progress.start) + + if err != nil { + // Errors are collected and displayed post-apply, so no need to + // re-render them here. Instead just signal that this resource failed + // to apply. + h.view.Hook(json.NewApplyErrored(addr, progress.action, elapsed)) + } else { + idKey, idValue := format.ObjectValueID(newState) + h.view.Hook(json.NewApplyComplete(addr, progress.action, idKey, idValue, elapsed)) + } + return terraform.HookActionContinue, nil +} + +func (h *jsonHook) PreProvisionInstanceStep(addr addrs.AbsResourceInstance, typeName string) (terraform.HookAction, error) { + h.view.Hook(json.NewProvisionStart(addr, typeName)) + return terraform.HookActionContinue, nil +} + +func (h *jsonHook) PostProvisionInstanceStep(addr addrs.AbsResourceInstance, typeName string, err error) (terraform.HookAction, error) { + if err != nil { + // Errors are collected and displayed post-apply, so no need to + // re-render them here. Instead just signal that this provisioner step + // failed. + h.view.Hook(json.NewProvisionErrored(addr, typeName)) + } else { + h.view.Hook(json.NewProvisionComplete(addr, typeName)) + } + return terraform.HookActionContinue, nil +} + +func (h *jsonHook) ProvisionOutput(addr addrs.AbsResourceInstance, typeName string, msg string) { + s := bufio.NewScanner(strings.NewReader(msg)) + s.Split(scanLines) + for s.Scan() { + line := strings.TrimRightFunc(s.Text(), unicode.IsSpace) + if line != "" { + h.view.Hook(json.NewProvisionProgress(addr, typeName, line)) + } + } +} + +func (h *jsonHook) PreRefresh(addr addrs.AbsResourceInstance, gen states.Generation, priorState cty.Value) (terraform.HookAction, error) { + idKey, idValue := format.ObjectValueID(priorState) + h.view.Hook(json.NewRefreshStart(addr, idKey, idValue)) + return terraform.HookActionContinue, nil +} + +func (h *jsonHook) PostRefresh(addr addrs.AbsResourceInstance, gen states.Generation, priorState cty.Value, newState cty.Value) (terraform.HookAction, error) { + idKey, idValue := format.ObjectValueID(newState) + h.view.Hook(json.NewRefreshComplete(addr, idKey, idValue)) + return terraform.HookActionContinue, nil +} diff --git a/command/views/hook_json_test.go b/command/views/hook_json_test.go new file mode 100644 index 000000000..ee20c450b --- /dev/null +++ b/command/views/hook_json_test.go @@ -0,0 +1,325 @@ +package views + +import ( + "fmt" + "testing" + "time" + + "github.com/hashicorp/terraform/addrs" + "github.com/hashicorp/terraform/internal/terminal" + "github.com/hashicorp/terraform/plans" + "github.com/hashicorp/terraform/states" + "github.com/hashicorp/terraform/terraform" + "github.com/zclconf/go-cty/cty" +) + +// Test a sequence of hooks associated with creating a resource +func TestJSONHook_create(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + hook := newJSONHook(NewJSONView(NewView(streams))) + + now := time.Now() + hook.timeNow = func() time.Time { return now } + after := make(chan time.Time, 1) + hook.timeAfter = func(time.Duration) <-chan time.Time { return after } + + addr := addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "boop", + }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance) + priorState := cty.NullVal(cty.Object(map[string]cty.Type{ + "id": cty.String, + "bar": cty.List(cty.String), + })) + plannedNewState := cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("test"), + "bar": cty.ListVal([]cty.Value{ + cty.StringVal("baz"), + }), + }) + + action, err := hook.PreApply(addr, states.CurrentGen, plans.Create, priorState, plannedNewState) + testHookReturnValues(t, action, err) + + action, err = hook.PreProvisionInstanceStep(addr, "local-exec") + testHookReturnValues(t, action, err) + + hook.ProvisionOutput(addr, "local-exec", `Executing: ["/bin/sh" "-c" "touch /etc/motd"]`) + + action, err = hook.PostProvisionInstanceStep(addr, "local-exec", nil) + testHookReturnValues(t, action, err) + + // Travel 10s into the future, notify the progress goroutine, and sleep + // briefly to allow it to execute + now = now.Add(10 * time.Second) + after <- now + time.Sleep(1 * time.Millisecond) + + // Travel 10s into the future, notify the progress goroutine, and sleep + // briefly to allow it to execute + now = now.Add(10 * time.Second) + after <- now + time.Sleep(1 * time.Millisecond) + + // Travel 2s into the future. We have arrived! + now = now.Add(2 * time.Second) + + action, err = hook.PostApply(addr, states.CurrentGen, plannedNewState, nil) + testHookReturnValues(t, action, err) + + // Shut down the progress goroutine if still active + hook.applyingLock.Lock() + for key, progress := range hook.applying { + close(progress.done) + <-progress.heartbeatDone + delete(hook.applying, key) + } + hook.applyingLock.Unlock() + + wantResource := map[string]interface{}{ + "addr": string("test_instance.boop"), + "implied_provider": string("test"), + "module": string(""), + "resource": string("test_instance.boop"), + "resource_key": nil, + "resource_name": string("boop"), + "resource_type": string("test_instance"), + } + want := []map[string]interface{}{ + { + "@level": "info", + "@message": "test_instance.boop: Creating...", + "@module": "terraform.ui", + "type": "apply_start", + "hook": map[string]interface{}{ + "action": string("create"), + "resource": wantResource, + }, + }, + { + "@level": "info", + "@message": "test_instance.boop: Provisioning with 'local-exec'...", + "@module": "terraform.ui", + "type": "provision_start", + "hook": map[string]interface{}{ + "provisioner": "local-exec", + "resource": wantResource, + }, + }, + { + "@level": "info", + "@message": `test_instance.boop: (local-exec): Executing: ["/bin/sh" "-c" "touch /etc/motd"]`, + "@module": "terraform.ui", + "type": "provision_progress", + "hook": map[string]interface{}{ + "output": `Executing: ["/bin/sh" "-c" "touch /etc/motd"]`, + "provisioner": "local-exec", + "resource": wantResource, + }, + }, + { + "@level": "info", + "@message": "test_instance.boop: (local-exec) Provisioning complete", + "@module": "terraform.ui", + "type": "provision_complete", + "hook": map[string]interface{}{ + "provisioner": "local-exec", + "resource": wantResource, + }, + }, + { + "@level": "info", + "@message": "test_instance.boop: Still creating... [10s elapsed]", + "@module": "terraform.ui", + "type": "apply_progress", + "hook": map[string]interface{}{ + "action": string("create"), + "elapsed_seconds": float64(10), + "resource": wantResource, + }, + }, + { + "@level": "info", + "@message": "test_instance.boop: Still creating... [20s elapsed]", + "@module": "terraform.ui", + "type": "apply_progress", + "hook": map[string]interface{}{ + "action": string("create"), + "elapsed_seconds": float64(20), + "resource": wantResource, + }, + }, + { + "@level": "info", + "@message": "test_instance.boop: Creation complete after 22s [id=test]", + "@module": "terraform.ui", + "type": "apply_complete", + "hook": map[string]interface{}{ + "action": string("create"), + "elapsed_seconds": float64(22), + "id_key": "id", + "id_value": "test", + "resource": wantResource, + }, + }, + } + + testJSONViewOutputEquals(t, done(t).Stdout(), want) +} + +func TestJSONHook_errors(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + hook := newJSONHook(NewJSONView(NewView(streams))) + + addr := addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "boop", + }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance) + priorState := cty.NullVal(cty.Object(map[string]cty.Type{ + "id": cty.String, + "bar": cty.List(cty.String), + })) + plannedNewState := cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("test"), + "bar": cty.ListVal([]cty.Value{ + cty.StringVal("baz"), + }), + }) + + action, err := hook.PreApply(addr, states.CurrentGen, plans.Delete, priorState, plannedNewState) + testHookReturnValues(t, action, err) + + provisionError := fmt.Errorf("provisioner didn't want to") + action, err = hook.PostProvisionInstanceStep(addr, "local-exec", provisionError) + testHookReturnValues(t, action, err) + + applyError := fmt.Errorf("provider was sad") + action, err = hook.PostApply(addr, states.CurrentGen, plannedNewState, applyError) + testHookReturnValues(t, action, err) + + // Shut down the progress goroutine + hook.applyingLock.Lock() + for key, progress := range hook.applying { + close(progress.done) + <-progress.heartbeatDone + delete(hook.applying, key) + } + hook.applyingLock.Unlock() + + wantResource := map[string]interface{}{ + "addr": string("test_instance.boop"), + "implied_provider": string("test"), + "module": string(""), + "resource": string("test_instance.boop"), + "resource_key": nil, + "resource_name": string("boop"), + "resource_type": string("test_instance"), + } + want := []map[string]interface{}{ + { + "@level": "info", + "@message": "test_instance.boop: Destroying...", + "@module": "terraform.ui", + "type": "apply_start", + "hook": map[string]interface{}{ + "action": string("delete"), + "resource": wantResource, + }, + }, + { + "@level": "info", + "@message": "test_instance.boop: (local-exec) Provisioning errored", + "@module": "terraform.ui", + "type": "provision_errored", + "hook": map[string]interface{}{ + "provisioner": "local-exec", + "resource": wantResource, + }, + }, + { + "@level": "info", + "@message": "test_instance.boop: Destruction errored after 0s", + "@module": "terraform.ui", + "type": "apply_errored", + "hook": map[string]interface{}{ + "action": string("delete"), + "elapsed_seconds": float64(0), + "resource": wantResource, + }, + }, + } + + testJSONViewOutputEquals(t, done(t).Stdout(), want) +} + +func TestJSONHook_refresh(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + hook := newJSONHook(NewJSONView(NewView(streams))) + + addr := addrs.Resource{ + Mode: addrs.DataResourceMode, + Type: "test_data_source", + Name: "beep", + }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance) + state := cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("honk"), + "bar": cty.ListVal([]cty.Value{ + cty.StringVal("baz"), + }), + }) + + action, err := hook.PreRefresh(addr, states.CurrentGen, state) + testHookReturnValues(t, action, err) + + action, err = hook.PostRefresh(addr, states.CurrentGen, state, state) + testHookReturnValues(t, action, err) + + wantResource := map[string]interface{}{ + "addr": string("data.test_data_source.beep"), + "implied_provider": string("test"), + "module": string(""), + "resource": string("data.test_data_source.beep"), + "resource_key": nil, + "resource_name": string("beep"), + "resource_type": string("test_data_source"), + } + want := []map[string]interface{}{ + { + "@level": "info", + "@message": "data.test_data_source.beep: Refreshing state... [id=honk]", + "@module": "terraform.ui", + "type": "refresh_start", + "hook": map[string]interface{}{ + "resource": wantResource, + "id_key": "id", + "id_value": "honk", + }, + }, + { + "@level": "info", + "@message": "data.test_data_source.beep: Refresh complete [id=honk]", + "@module": "terraform.ui", + "type": "refresh_complete", + "hook": map[string]interface{}{ + "resource": wantResource, + "id_key": "id", + "id_value": "honk", + }, + }, + } + + testJSONViewOutputEquals(t, done(t).Stdout(), want) +} + +func testHookReturnValues(t *testing.T, action terraform.HookAction, err error) { + t.Helper() + + if err != nil { + t.Fatal(err) + } + if action != terraform.HookActionContinue { + t.Fatalf("Expected hook to continue, given: %#v", action) + } +} diff --git a/command/views/json/change.go b/command/views/json/change.go new file mode 100644 index 000000000..12c8aed5c --- /dev/null +++ b/command/views/json/change.go @@ -0,0 +1,55 @@ +package json + +import ( + "fmt" + + "github.com/hashicorp/terraform/plans" +) + +func NewResourceInstanceChange(change *plans.ResourceInstanceChangeSrc) *ResourceInstanceChange { + c := &ResourceInstanceChange{ + Resource: newResourceAddr(change.Addr), + Action: changeAction(change.Action), + } + + return c +} + +type ResourceInstanceChange struct { + Resource ResourceAddr `json:"resource"` + Action ChangeAction `json:"action"` +} + +func (c *ResourceInstanceChange) String() string { + return fmt.Sprintf("%s: Plan to %s", c.Resource.Addr, c.Action) +} + +type ChangeAction string + +const ( + ActionNoOp ChangeAction = "noop" + ActionCreate ChangeAction = "create" + ActionRead ChangeAction = "read" + ActionUpdate ChangeAction = "update" + ActionReplace ChangeAction = "replace" + ActionDelete ChangeAction = "delete" +) + +func changeAction(action plans.Action) ChangeAction { + switch action { + case plans.NoOp: + return ActionNoOp + case plans.Create: + return ActionCreate + case plans.Read: + return ActionRead + case plans.Update: + return ActionUpdate + case plans.DeleteThenCreate, plans.CreateThenDelete: + return ActionReplace + case plans.Delete: + return ActionDelete + default: + return ActionNoOp + } +} diff --git a/command/views/json/change_summary.go b/command/views/json/change_summary.go new file mode 100644 index 000000000..2b87f62e2 --- /dev/null +++ b/command/views/json/change_summary.go @@ -0,0 +1,34 @@ +package json + +import "fmt" + +type Operation string + +const ( + OperationApplied Operation = "apply" + OperationDestroyed Operation = "destroy" + OperationPlanned Operation = "plan" +) + +type ChangeSummary struct { + Add int `json:"add"` + Change int `json:"change"` + Remove int `json:"remove"` + Operation Operation `json:"operation"` +} + +// The summary strings for apply and plan are accidentally a public interface +// used by Terraform Cloud and Terraform Enterprise, so the exact formats of +// these strings are important. +func (cs *ChangeSummary) String() string { + switch cs.Operation { + case OperationApplied: + return fmt.Sprintf("Apply complete! Resources: %d added, %d changed, %d destroyed.", cs.Add, cs.Change, cs.Remove) + case OperationDestroyed: + return fmt.Sprintf("Destroy complete! Resources: %d destroyed.", cs.Remove) + case OperationPlanned: + return fmt.Sprintf("Plan: %d to add, %d to change, %d to destroy.", cs.Add, cs.Change, cs.Remove) + default: + return fmt.Sprintf("%s: %d add, %d change, %d destroy", cs.Operation, cs.Add, cs.Change, cs.Remove) + } +} diff --git a/command/views/json/hook.go b/command/views/json/hook.go new file mode 100644 index 000000000..0e8242000 --- /dev/null +++ b/command/views/json/hook.go @@ -0,0 +1,364 @@ +package json + +import ( + "fmt" + "time" + + "github.com/hashicorp/terraform/addrs" + "github.com/hashicorp/terraform/plans" +) + +type Hook interface { + HookType() MessageType + String() string +} + +// ApplyStart: triggered by PreApply hook +type applyStart struct { + Resource ResourceAddr `json:"resource"` + Action ChangeAction `json:"action"` + IDKey string `json:"id_key,omitempty"` + IDValue string `json:"id_value,omitempty"` + actionVerb string +} + +var _ Hook = (*applyStart)(nil) + +func (h *applyStart) HookType() MessageType { + return MessageApplyStart +} + +func (h *applyStart) String() string { + var id string + if h.IDKey != "" && h.IDValue != "" { + id = fmt.Sprintf(" [%s=%s]", h.IDKey, h.IDValue) + } + return fmt.Sprintf("%s: %s...%s", h.Resource.Addr, h.actionVerb, id) +} + +func NewApplyStart(addr addrs.AbsResourceInstance, action plans.Action, idKey string, idValue string) Hook { + hook := &applyStart{ + Resource: newResourceAddr(addr), + Action: changeAction(action), + IDKey: idKey, + IDValue: idValue, + actionVerb: startActionVerb(action), + } + + return hook +} + +// ApplyProgress: currently triggered by a timer started on PreApply. In +// future, this might also be triggered by provider progress reporting. +type applyProgress struct { + Resource ResourceAddr `json:"resource"` + Action ChangeAction `json:"action"` + Elapsed float64 `json:"elapsed_seconds"` + actionVerb string + elapsed time.Duration +} + +var _ Hook = (*applyProgress)(nil) + +func (h *applyProgress) HookType() MessageType { + return MessageApplyProgress +} + +func (h *applyProgress) String() string { + return fmt.Sprintf("%s: Still %s... [%s elapsed]", h.Resource.Addr, h.actionVerb, h.elapsed) +} + +func NewApplyProgress(addr addrs.AbsResourceInstance, action plans.Action, elapsed time.Duration) Hook { + return &applyProgress{ + Resource: newResourceAddr(addr), + Action: changeAction(action), + Elapsed: elapsed.Seconds(), + actionVerb: progressActionVerb(action), + elapsed: elapsed, + } +} + +// ApplyComplete: triggered by PostApply hook +type applyComplete struct { + Resource ResourceAddr `json:"resource"` + Action ChangeAction `json:"action"` + IDKey string `json:"id_key,omitempty"` + IDValue string `json:"id_value,omitempty"` + Elapsed float64 `json:"elapsed_seconds"` + actionNoun string + elapsed time.Duration +} + +var _ Hook = (*applyComplete)(nil) + +func (h *applyComplete) HookType() MessageType { + return MessageApplyComplete +} + +func (h *applyComplete) String() string { + var id string + if h.IDKey != "" && h.IDValue != "" { + id = fmt.Sprintf(" [%s=%s]", h.IDKey, h.IDValue) + } + return fmt.Sprintf("%s: %s complete after %s%s", h.Resource.Addr, h.actionNoun, h.elapsed, id) +} + +func NewApplyComplete(addr addrs.AbsResourceInstance, action plans.Action, idKey, idValue string, elapsed time.Duration) Hook { + return &applyComplete{ + Resource: newResourceAddr(addr), + Action: changeAction(action), + IDKey: idKey, + IDValue: idValue, + Elapsed: elapsed.Seconds(), + actionNoun: actionNoun(action), + elapsed: elapsed, + } +} + +// ApplyErrored: triggered by PostApply hook on failure. This will be followed +// by diagnostics when the apply finishes. +type applyErrored struct { + Resource ResourceAddr `json:"resource"` + Action ChangeAction `json:"action"` + Elapsed float64 `json:"elapsed_seconds"` + actionNoun string + elapsed time.Duration +} + +var _ Hook = (*applyErrored)(nil) + +func (h *applyErrored) HookType() MessageType { + return MessageApplyErrored +} + +func (h *applyErrored) String() string { + return fmt.Sprintf("%s: %s errored after %s", h.Resource.Addr, h.actionNoun, h.elapsed) +} + +func NewApplyErrored(addr addrs.AbsResourceInstance, action plans.Action, elapsed time.Duration) Hook { + return &applyErrored{ + Resource: newResourceAddr(addr), + Action: changeAction(action), + Elapsed: elapsed.Seconds(), + actionNoun: actionNoun(action), + elapsed: elapsed, + } +} + +// ProvisionStart: triggered by PreProvisionInstanceStep hook +type provisionStart struct { + Resource ResourceAddr `json:"resource"` + Provisioner string `json:"provisioner"` +} + +var _ Hook = (*provisionStart)(nil) + +func (h *provisionStart) HookType() MessageType { + return MessageProvisionStart +} + +func (h *provisionStart) String() string { + return fmt.Sprintf("%s: Provisioning with '%s'...", h.Resource.Addr, h.Provisioner) +} + +func NewProvisionStart(addr addrs.AbsResourceInstance, provisioner string) Hook { + return &provisionStart{ + Resource: newResourceAddr(addr), + Provisioner: provisioner, + } +} + +// ProvisionProgress: triggered by ProvisionOutput hook +type provisionProgress struct { + Resource ResourceAddr `json:"resource"` + Provisioner string `json:"provisioner"` + Output string `json:"output"` +} + +var _ Hook = (*provisionProgress)(nil) + +func (h *provisionProgress) HookType() MessageType { + return MessageProvisionProgress +} + +func (h *provisionProgress) String() string { + return fmt.Sprintf("%s: (%s): %s", h.Resource.Addr, h.Provisioner, h.Output) +} + +func NewProvisionProgress(addr addrs.AbsResourceInstance, provisioner string, output string) Hook { + return &provisionProgress{ + Resource: newResourceAddr(addr), + Provisioner: provisioner, + Output: output, + } +} + +// ProvisionComplete: triggered by PostProvisionInstanceStep hook +type provisionComplete struct { + Resource ResourceAddr `json:"resource"` + Provisioner string `json:"provisioner"` +} + +var _ Hook = (*provisionComplete)(nil) + +func (h *provisionComplete) HookType() MessageType { + return MessageProvisionComplete +} + +func (h *provisionComplete) String() string { + return fmt.Sprintf("%s: (%s) Provisioning complete", h.Resource.Addr, h.Provisioner) +} + +func NewProvisionComplete(addr addrs.AbsResourceInstance, provisioner string) Hook { + return &provisionComplete{ + Resource: newResourceAddr(addr), + Provisioner: provisioner, + } +} + +// ProvisionErrored: triggered by PostProvisionInstanceStep hook on failure. +// This will be followed by diagnostics when the apply finishes. +type provisionErrored struct { + Resource ResourceAddr `json:"resource"` + Provisioner string `json:"provisioner"` +} + +var _ Hook = (*provisionErrored)(nil) + +func (h *provisionErrored) HookType() MessageType { + return MessageProvisionErrored +} + +func (h *provisionErrored) String() string { + return fmt.Sprintf("%s: (%s) Provisioning errored", h.Resource.Addr, h.Provisioner) +} + +func NewProvisionErrored(addr addrs.AbsResourceInstance, provisioner string) Hook { + return &provisionErrored{ + Resource: newResourceAddr(addr), + Provisioner: provisioner, + } +} + +// RefreshStart: triggered by PreRefresh hook +type refreshStart struct { + Resource ResourceAddr `json:"resource"` + IDKey string `json:"id_key,omitempty"` + IDValue string `json:"id_value,omitempty"` +} + +var _ Hook = (*refreshStart)(nil) + +func (h *refreshStart) HookType() MessageType { + return MessageRefreshStart +} + +func (h *refreshStart) String() string { + var id string + if h.IDKey != "" && h.IDValue != "" { + id = fmt.Sprintf(" [%s=%s]", h.IDKey, h.IDValue) + } + return fmt.Sprintf("%s: Refreshing state...%s", h.Resource.Addr, id) +} + +func NewRefreshStart(addr addrs.AbsResourceInstance, idKey, idValue string) Hook { + return &refreshStart{ + Resource: newResourceAddr(addr), + IDKey: idKey, + IDValue: idValue, + } +} + +// RefreshComplete: triggered by PostRefresh hook +type refreshComplete struct { + Resource ResourceAddr `json:"resource"` + IDKey string `json:"id_key,omitempty"` + IDValue string `json:"id_value,omitempty"` +} + +var _ Hook = (*refreshComplete)(nil) + +func (h *refreshComplete) HookType() MessageType { + return MessageRefreshComplete +} + +func (h *refreshComplete) String() string { + var id string + if h.IDKey != "" && h.IDValue != "" { + id = fmt.Sprintf(" [%s=%s]", h.IDKey, h.IDValue) + } + return fmt.Sprintf("%s: Refresh complete%s", h.Resource.Addr, id) +} + +func NewRefreshComplete(addr addrs.AbsResourceInstance, idKey, idValue string) Hook { + return &refreshComplete{ + Resource: newResourceAddr(addr), + IDKey: idKey, + IDValue: idValue, + } +} + +// Convert the subset of plans.Action values we expect to receive into a +// present-tense verb for the applyStart hook message. +func startActionVerb(action plans.Action) string { + switch action { + case plans.Create: + return "Creating" + case plans.Update: + return "Modifying" + case plans.Delete: + return "Destroying" + case plans.Read: + return "Refreshing" + case plans.CreateThenDelete, plans.DeleteThenCreate: + // This is not currently possible to reach, as we receive separate + // passes for create and delete + return "Replacing" + default: + return "Applying" + } +} + +// Convert the subset of plans.Action values we expect to receive into a +// present-tense verb for the applyProgress hook message. This will be +// prefixed with "Still ", so it is lower-case. +func progressActionVerb(action plans.Action) string { + switch action { + case plans.Create: + return "creating" + case plans.Update: + return "modifying" + case plans.Delete: + return "destroying" + case plans.Read: + return "refreshing" + case plans.CreateThenDelete, plans.DeleteThenCreate: + // This is not currently possible to reach, as we receive separate + // passes for create and delete + return "replacing" + default: + return "applying" + } +} + +// Convert the subset of plans.Action values we expect to receive into a +// noun for the applyComplete and applyErrored hook messages. This will be +// combined into a phrase like "Creation complete after 1m4s". +func actionNoun(action plans.Action) string { + switch action { + case plans.Create: + return "Creation" + case plans.Update: + return "Modifications" + case plans.Delete: + return "Destruction" + case plans.Read: + return "Refresh" + case plans.CreateThenDelete, plans.DeleteThenCreate: + // This is not currently possible to reach, as we receive separate + // passes for create and delete + return "Replacement" + default: + return "Apply" + } +} diff --git a/command/views/json/message_types.go b/command/views/json/message_types.go new file mode 100644 index 000000000..eb10c7b1e --- /dev/null +++ b/command/views/json/message_types.go @@ -0,0 +1,27 @@ +package json + +type MessageType string + +const ( + // Generic messages + MessageVersion MessageType = "version" + MessageLog MessageType = "log" + MessageDiagnostic MessageType = "diagnostic" + + // Operation results + MessagePlannedChange MessageType = "planned_change" + MessageChangeSummary MessageType = "change_summary" + MessageOutputs MessageType = "outputs" + + // Hook-driven messages + MessageApplyStart MessageType = "apply_start" + MessageApplyProgress MessageType = "apply_progress" + MessageApplyComplete MessageType = "apply_complete" + MessageApplyErrored MessageType = "apply_errored" + MessageProvisionStart MessageType = "provision_start" + MessageProvisionProgress MessageType = "provision_progress" + MessageProvisionComplete MessageType = "provision_complete" + MessageProvisionErrored MessageType = "provision_errored" + MessageRefreshStart MessageType = "refresh_start" + MessageRefreshComplete MessageType = "refresh_complete" +) diff --git a/command/views/json/output.go b/command/views/json/output.go new file mode 100644 index 000000000..1cb19a431 --- /dev/null +++ b/command/views/json/output.go @@ -0,0 +1,55 @@ +package json + +import ( + "encoding/json" + "fmt" + + ctyjson "github.com/zclconf/go-cty/cty/json" + + "github.com/hashicorp/terraform/states" + "github.com/hashicorp/terraform/tfdiags" +) + +type Output struct { + Sensitive bool `json:"sensitive"` + Type json.RawMessage `json:"type"` + Value json.RawMessage `json:"value"` +} + +type Outputs map[string]Output + +func OutputsFromMap(outputValues map[string]*states.OutputValue) (Outputs, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + + outputs := make(map[string]Output, len(outputValues)) + + for name, ov := range outputValues { + unmarked, _ := ov.Value.UnmarkDeep() + value, err := ctyjson.Marshal(unmarked, unmarked.Type()) + if err != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + fmt.Sprintf("Error serializing output %q", name), + fmt.Sprintf("Error: %s", err), + )) + return nil, diags + } + valueType, err := ctyjson.MarshalType(unmarked.Type()) + if err != nil { + diags = diags.Append(err) + return nil, diags + } + + outputs[name] = Output{ + Sensitive: ov.Sensitive, + Type: json.RawMessage(valueType), + Value: json.RawMessage(value), + } + } + + return outputs, nil +} + +func (o Outputs) String() string { + return fmt.Sprintf("Outputs: %d", len(o)) +} diff --git a/command/views/json/output_test.go b/command/views/json/output_test.go new file mode 100644 index 000000000..4babfa2c5 --- /dev/null +++ b/command/views/json/output_test.go @@ -0,0 +1,87 @@ +package json + +import ( + "encoding/json" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform/states" + "github.com/zclconf/go-cty/cty" +) + +func TestOutputsFromMap(t *testing.T) { + got, diags := OutputsFromMap(map[string]*states.OutputValue{ + // Normal non-sensitive output + "boop": { + Value: cty.NumberIntVal(1234), + }, + // Sensitive string output + "beep": { + Value: cty.StringVal("horse-battery").Mark("sensitive"), + Sensitive: true, + }, + // Sensitive object output which is marked at the leaf + "blorp": { + Value: cty.ObjectVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "b": cty.ObjectVal(map[string]cty.Value{ + "c": cty.StringVal("oh, hi").Mark("sensitive"), + }), + }), + }), + Sensitive: true, + }, + // Null value + "honk": { + Value: cty.NullVal(cty.Map(cty.Bool)), + }, + }) + if len(diags) > 0 { + t.Fatal(diags.Err()) + } + + want := Outputs{ + "boop": { + Sensitive: false, + Type: json.RawMessage(`"number"`), + Value: json.RawMessage(`1234`), + }, + "beep": { + Sensitive: true, + Type: json.RawMessage(`"string"`), + Value: json.RawMessage(`"horse-battery"`), + }, + "blorp": { + Sensitive: true, + Type: json.RawMessage(`["object",{"a":["object",{"b":["object",{"c":"string"}]}]}]`), + Value: json.RawMessage(`{"a":{"b":{"c":"oh, hi"}}}`), + }, + "honk": { + Sensitive: false, + Type: json.RawMessage(`["map","bool"]`), + Value: json.RawMessage(`null`), + }, + } + + if !cmp.Equal(want, got) { + t.Fatalf("unexpected result\n%s", cmp.Diff(want, got)) + } +} + +func TestOutputs_String(t *testing.T) { + outputs := Outputs{ + "boop": { + Sensitive: false, + Type: json.RawMessage(`"number"`), + Value: json.RawMessage(`1234`), + }, + "beep": { + Sensitive: true, + Type: json.RawMessage(`"string"`), + Value: json.RawMessage(`"horse-battery"`), + }, + } + if got, want := outputs.String(), "Outputs: 2"; got != want { + t.Fatalf("unexpected value\n got: %q\nwant: %q", got, want) + } +} diff --git a/command/views/json/resource_addr.go b/command/views/json/resource_addr.go new file mode 100644 index 000000000..414ce33d8 --- /dev/null +++ b/command/views/json/resource_addr.go @@ -0,0 +1,34 @@ +package json + +import ( + "github.com/zclconf/go-cty/cty" + ctyjson "github.com/zclconf/go-cty/cty/json" + + "github.com/hashicorp/terraform/addrs" +) + +type ResourceAddr struct { + Addr string `json:"addr"` + Module string `json:"module"` + Resource string `json:"resource"` + ImpliedProvider string `json:"implied_provider"` + ResourceType string `json:"resource_type"` + ResourceName string `json:"resource_name"` + ResourceKey ctyjson.SimpleJSONValue `json:"resource_key"` +} + +func newResourceAddr(addr addrs.AbsResourceInstance) ResourceAddr { + resourceKey := ctyjson.SimpleJSONValue{Value: cty.NilVal} + if addr.Resource.Key != nil { + resourceKey.Value = addr.Resource.Key.Value() + } + return ResourceAddr{ + Addr: addr.String(), + Module: addr.Module.String(), + Resource: addr.Resource.String(), + ImpliedProvider: addr.Resource.Resource.ImpliedProvider(), + ResourceType: addr.Resource.Resource.Type, + ResourceName: addr.Resource.Resource.Name, + ResourceKey: resourceKey, + } +} diff --git a/command/views/json_view.go b/command/views/json_view.go new file mode 100644 index 000000000..fdb851c3f --- /dev/null +++ b/command/views/json_view.go @@ -0,0 +1,120 @@ +package views + +import ( + encJson "encoding/json" + "fmt" + + "github.com/hashicorp/go-hclog" + "github.com/hashicorp/terraform/command/views/json" + "github.com/hashicorp/terraform/tfdiags" + tfversion "github.com/hashicorp/terraform/version" +) + +// This version describes the schema of JSON UI messages. This version must be +// updated after making any changes to this view, the jsonHook, or any of the +// command/views/json package. +const JSON_UI_VERSION = "0.1.0" + +func NewJSONView(view *View) *JSONView { + log := hclog.New(&hclog.LoggerOptions{ + Name: "terraform.ui", + Output: view.streams.Stdout.File, + JSONFormat: true, + }) + jv := &JSONView{ + log: log, + view: view, + } + jv.Version() + return jv +} + +type JSONView struct { + // hclog is used for all output in JSON UI mode. The logger has an internal + // mutex to ensure that messages are not interleaved. + log hclog.Logger + + // We hold a reference to the view entirely to allow us to access the + // ConfigSources function pointer, in order to render source snippets into + // diagnostics. This is even more unfortunate than the same reference in the + // view. + // + // Do not be tempted to dereference the configSource value upon logger init, + // as it will likely be updated later. + view *View +} + +func (v *JSONView) Version() { + version := tfversion.String() + v.log.Info( + fmt.Sprintf("Terraform %s", version), + "type", json.MessageVersion, + "terraform", version, + "ui", JSON_UI_VERSION, + ) +} + +func (v *JSONView) Log(message string) { + v.log.Info(message, "type", json.MessageLog) +} + +func (v *JSONView) StateDump(state string) { + v.log.Info( + "Emergency state dump", + "type", json.MessageLog, + "state", encJson.RawMessage(state), + ) +} + +func (v *JSONView) Diagnostics(diags tfdiags.Diagnostics) { + sources := v.view.configSources() + for _, diag := range diags { + diagnostic := json.NewDiagnostic(diag, sources) + switch diag.Severity() { + case tfdiags.Warning: + v.log.Warn( + fmt.Sprintf("Warning: %s", diag.Description().Summary), + "type", json.MessageDiagnostic, + "diagnostic", diagnostic, + ) + default: + v.log.Error( + fmt.Sprintf("Error: %s", diag.Description().Summary), + "type", json.MessageDiagnostic, + "diagnostic", diagnostic, + ) + } + } +} + +func (v *JSONView) PlannedChange(c *json.ResourceInstanceChange) { + v.log.Info( + c.String(), + "type", json.MessagePlannedChange, + "change", c, + ) +} + +func (v *JSONView) ChangeSummary(cs *json.ChangeSummary) { + v.log.Info( + cs.String(), + "type", json.MessageChangeSummary, + "changes", cs, + ) +} + +func (v *JSONView) Hook(h json.Hook) { + v.log.Info( + h.String(), + "type", h.HookType(), + "hook", h, + ) +} + +func (v *JSONView) Outputs(outputs json.Outputs) { + v.log.Info( + outputs.String(), + "type", json.MessageOutputs, + "outputs", outputs, + ) +} diff --git a/command/views/json_view_test.go b/command/views/json_view_test.go new file mode 100644 index 000000000..9aa4c692d --- /dev/null +++ b/command/views/json_view_test.go @@ -0,0 +1,307 @@ +package views + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform/addrs" + viewsjson "github.com/hashicorp/terraform/command/views/json" + "github.com/hashicorp/terraform/internal/terminal" + "github.com/hashicorp/terraform/plans" + "github.com/hashicorp/terraform/tfdiags" + tfversion "github.com/hashicorp/terraform/version" +) + +// Calling NewJSONView should also always output a version message, which is a +// convenient way to test that NewJSONView works. +func TestNewJSONView(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + NewJSONView(NewView(streams)) + + version := tfversion.String() + want := []map[string]interface{}{ + { + "@level": "info", + "@message": fmt.Sprintf("Terraform %s", version), + "@module": "terraform.ui", + "type": "version", + "terraform": version, + "ui": JSON_UI_VERSION, + }, + } + + testJSONViewOutputEqualsFull(t, done(t).Stdout(), want) +} + +func TestJSONView_Log(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + jv := NewJSONView(NewView(streams)) + + jv.Log("hello, world") + + want := []map[string]interface{}{ + { + "@level": "info", + "@message": "hello, world", + "@module": "terraform.ui", + "type": "log", + }, + } + testJSONViewOutputEquals(t, done(t).Stdout(), want) +} + +// This test covers only the basics of JSON diagnostic rendering, as more +// complex diagnostics are tested elsewhere. +func TestJSONView_Diagnostics(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + jv := NewJSONView(NewView(streams)) + + var diags tfdiags.Diagnostics + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Warning, + `Improper use of "less"`, + `You probably mean "10 buckets or fewer"`, + )) + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Unusually stripey cat detected", + "Are you sure this random_pet isn't a cheetah?", + )) + + jv.Diagnostics(diags) + + want := []map[string]interface{}{ + { + "@level": "warn", + "@message": `Warning: Improper use of "less"`, + "@module": "terraform.ui", + "type": "diagnostic", + "diagnostic": map[string]interface{}{ + "severity": "warning", + "summary": `Improper use of "less"`, + "detail": `You probably mean "10 buckets or fewer"`, + }, + }, + { + "@level": "error", + "@message": "Error: Unusually stripey cat detected", + "@module": "terraform.ui", + "type": "diagnostic", + "diagnostic": map[string]interface{}{ + "severity": "error", + "summary": "Unusually stripey cat detected", + "detail": "Are you sure this random_pet isn't a cheetah?", + }, + }, + } + testJSONViewOutputEquals(t, done(t).Stdout(), want) +} + +func TestJSONView_PlannedChange(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + jv := NewJSONView(NewView(streams)) + + foo, diags := addrs.ParseModuleInstanceStr("module.foo") + if len(diags) > 0 { + t.Fatal(diags.Err()) + } + managed := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_instance", Name: "bar"} + cs := &plans.ResourceInstanceChangeSrc{ + Addr: managed.Instance(addrs.StringKey("boop")).Absolute(foo), + ChangeSrc: plans.ChangeSrc{ + Action: plans.Create, + }, + } + jv.PlannedChange(viewsjson.NewResourceInstanceChange(cs)) + + want := []map[string]interface{}{ + { + "@level": "info", + "@message": `module.foo.test_instance.bar["boop"]: Plan to create`, + "@module": "terraform.ui", + "type": "planned_change", + "change": map[string]interface{}{ + "action": "create", + "resource": map[string]interface{}{ + "addr": `module.foo.test_instance.bar["boop"]`, + "implied_provider": "test", + "module": "module.foo", + "resource": `test_instance.bar["boop"]`, + "resource_key": "boop", + "resource_name": "bar", + "resource_type": "test_instance", + }, + }, + }, + } + testJSONViewOutputEquals(t, done(t).Stdout(), want) +} + +func TestJSONView_ChangeSummary(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + jv := NewJSONView(NewView(streams)) + + jv.ChangeSummary(&viewsjson.ChangeSummary{ + Add: 1, + Change: 2, + Remove: 3, + Operation: viewsjson.OperationApplied, + }) + + want := []map[string]interface{}{ + { + "@level": "info", + "@message": "Apply complete! Resources: 1 added, 2 changed, 3 destroyed.", + "@module": "terraform.ui", + "type": "change_summary", + "changes": map[string]interface{}{ + "add": float64(1), + "change": float64(2), + "remove": float64(3), + "operation": "apply", + }, + }, + } + testJSONViewOutputEquals(t, done(t).Stdout(), want) +} + +func TestJSONView_Hook(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + jv := NewJSONView(NewView(streams)) + + foo, diags := addrs.ParseModuleInstanceStr("module.foo") + if len(diags) > 0 { + t.Fatal(diags.Err()) + } + managed := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_instance", Name: "bar"} + addr := managed.Instance(addrs.StringKey("boop")).Absolute(foo) + hook := viewsjson.NewApplyComplete(addr, plans.Create, "id", "boop-beep", 34*time.Second) + + jv.Hook(hook) + + want := []map[string]interface{}{ + { + "@level": "info", + "@message": `module.foo.test_instance.bar["boop"]: Creation complete after 34s [id=boop-beep]`, + "@module": "terraform.ui", + "type": "apply_complete", + "hook": map[string]interface{}{ + "resource": map[string]interface{}{ + "addr": `module.foo.test_instance.bar["boop"]`, + "implied_provider": "test", + "module": "module.foo", + "resource": `test_instance.bar["boop"]`, + "resource_key": "boop", + "resource_name": "bar", + "resource_type": "test_instance", + }, + "action": "create", + "id_key": "id", + "id_value": "boop-beep", + "elapsed_seconds": float64(34), + }, + }, + } + testJSONViewOutputEquals(t, done(t).Stdout(), want) +} + +func TestJSONView_Outputs(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + jv := NewJSONView(NewView(streams)) + + jv.Outputs(viewsjson.Outputs{ + "boop_count": { + Sensitive: false, + Value: json.RawMessage(`92`), + Type: json.RawMessage(`"number"`), + }, + "password": { + Sensitive: true, + Value: json.RawMessage(`"horse-battery"`), + Type: json.RawMessage(`"string"`), + }, + }) + + want := []map[string]interface{}{ + { + "@level": "info", + "@message": "Outputs: 2", + "@module": "terraform.ui", + "type": "outputs", + "outputs": map[string]interface{}{ + "boop_count": map[string]interface{}{ + "sensitive": false, + "value": float64(92), + "type": "number", + }, + "password": map[string]interface{}{ + "sensitive": true, + "value": "horse-battery", + "type": "string", + }, + }, + }, + } + testJSONViewOutputEquals(t, done(t).Stdout(), want) +} + +// This helper function tests a possibly multi-line JSONView output string +// against a slice of structs representing the desired log messages. It +// verifies that the output of JSONView is in JSON log format, one message per +// line. +func testJSONViewOutputEqualsFull(t *testing.T, output string, want []map[string]interface{}) { + t.Helper() + + // Remove final trailing newline + output = strings.TrimSuffix(output, "\n") + + // Split log into lines, each of which should be a JSON log message + gotLines := strings.Split(output, "\n") + + if len(gotLines) != len(want) { + t.Fatalf("unexpected number of messages. got %d, want %d", len(gotLines), len(want)) + } + + // Unmarshal each line and compare to the expected value + for i := range gotLines { + var gotStruct map[string]interface{} + wantStruct := want[i] + + if err := json.Unmarshal([]byte(gotLines[i]), &gotStruct); err != nil { + t.Fatal(err) + } + + if timestamp, ok := gotStruct["@timestamp"]; !ok { + t.Errorf("message has no timestamp: %#v", gotStruct) + } else { + // Remove the timestamp value from the struct to allow comparison + delete(gotStruct, "@timestamp") + + // Verify the timestamp format + if _, err := time.Parse("2006-01-02T15:04:05.000000Z07:00", timestamp.(string)); err != nil { + t.Fatalf("error parsing timestamp: %s", err) + } + } + + if !cmp.Equal(wantStruct, gotStruct) { + t.Fatalf("unexpected output on line %d:\n%s", i, cmp.Diff(wantStruct, gotStruct)) + } + } +} + +// testJSONViewOutputEquals skips the first line of output, since it ought to +// be a version message that we don't care about for most of our tests. +func testJSONViewOutputEquals(t *testing.T, output string, want []map[string]interface{}) { + t.Helper() + + // Remove up to the first newline + index := strings.Index(output, "\n") + if index >= 0 { + output = output[index+1:] + } + testJSONViewOutputEqualsFull(t, output, want) +} diff --git a/command/views/operation.go b/command/views/operation.go index 4252561bc..9d61ef26e 100644 --- a/command/views/operation.go +++ b/command/views/operation.go @@ -5,8 +5,10 @@ import ( "fmt" "strings" + "github.com/hashicorp/terraform/addrs" "github.com/hashicorp/terraform/command/arguments" "github.com/hashicorp/terraform/command/format" + "github.com/hashicorp/terraform/command/views/json" "github.com/hashicorp/terraform/plans" "github.com/hashicorp/terraform/states" "github.com/hashicorp/terraform/states/statefile" @@ -22,6 +24,7 @@ type Operation interface { EmergencyDumpState(stateFile *statefile.File) error + PlannedChange(change *plans.ResourceInstanceChangeSrc) PlanNoChanges() Plan(plan *plans.Plan, baseState *states.State, schemas *terraform.Schemas) PlanNextStep(planPath string) @@ -61,16 +64,6 @@ func (v *OperationHuman) FatalInterrupt() { v.view.streams.Eprintln(format.WordWrap(fatalInterrupt, v.view.errorColumns())) } -const fatalInterrupt = ` -Two interrupts received. Exiting immediately. Note that data loss may have occurred. -` - -const interrupted = ` -Interrupt received. -Please wait for Terraform to exit or data loss may occur. -Gracefully shutting down... -` - func (v *OperationHuman) Stopping() { v.view.streams.Println("Stopping operation...") } @@ -103,6 +96,9 @@ func (v *OperationHuman) Plan(plan *plans.Plan, baseState *states.State, schemas renderPlan(plan, baseState, schemas, v.view) } +func (v *OperationHuman) PlannedChange(change *plans.ResourceInstanceChangeSrc) { +} + // PlanNextStep gives the user some next-steps, unless we're running in an // automation tool which is presumed to provide its own UI for further actions. func (v *OperationHuman) PlanNextStep(planPath string) { @@ -127,6 +123,111 @@ func (v *OperationHuman) Diagnostics(diags tfdiags.Diagnostics) { v.view.Diagnostics(diags) } +type OperationJSON struct { + view *JSONView +} + +var _ Operation = (*OperationJSON)(nil) + +func (v *OperationJSON) Interrupted() { + v.view.Log(interrupted) +} + +func (v *OperationJSON) FatalInterrupt() { + v.view.Log(fatalInterrupt) +} + +func (v *OperationJSON) Stopping() { + v.view.Log("Stopping operation...") +} + +func (v *OperationJSON) Cancelled(planMode plans.Mode) { + switch planMode { + case plans.DestroyMode: + v.view.Log("Destroy cancelled") + default: + v.view.Log("Apply cancelled") + } +} + +func (v *OperationJSON) EmergencyDumpState(stateFile *statefile.File) error { + stateBuf := new(bytes.Buffer) + jsonErr := statefile.Write(stateFile, stateBuf) + if jsonErr != nil { + return jsonErr + } + v.view.StateDump(stateBuf.String()) + return nil +} + +// Log an empty change summary. +func (v *OperationJSON) PlanNoChanges() { + v.view.ChangeSummary(&json.ChangeSummary{ + Add: 0, + Change: 0, + Remove: 0, + Operation: json.OperationPlanned, + }) +} + +// Log a change summary and a series of "planned" messages for the changes in +// the plan. +func (v *OperationJSON) Plan(plan *plans.Plan, baseState *states.State, schemas *terraform.Schemas) { + cs := &json.ChangeSummary{ + Operation: json.OperationPlanned, + } + for _, change := range plan.Changes.Resources { + if change.Action == plans.Delete && change.Addr.Resource.Resource.Mode == addrs.DataResourceMode { + // Avoid rendering data sources on deletion + continue + } + switch change.Action { + case plans.Create: + cs.Add++ + case plans.Delete: + cs.Remove++ + case plans.Update: + cs.Change++ + case plans.CreateThenDelete, plans.DeleteThenCreate: + cs.Add++ + cs.Remove++ + } + + if change.Action != plans.NoOp { + v.view.PlannedChange(json.NewResourceInstanceChange(change)) + } + } + + v.view.ChangeSummary(cs) +} + +func (v *OperationJSON) PlannedChange(change *plans.ResourceInstanceChangeSrc) { + if change.Action == plans.Delete && change.Addr.Resource.Resource.Mode == addrs.DataResourceMode { + // Avoid rendering data sources on deletion + return + } + v.view.PlannedChange(json.NewResourceInstanceChange(change)) +} + +// PlanNextStep does nothing for the JSON view as it is a hook for user-facing +// output only applicable to human-readable UI. +func (v *OperationJSON) PlanNextStep(planPath string) { +} + +func (v *OperationJSON) Diagnostics(diags tfdiags.Diagnostics) { + v.view.Diagnostics(diags) +} + +const fatalInterrupt = ` +Two interrupts received. Exiting immediately. Note that data loss may have occurred. +` + +const interrupted = ` +Interrupt received. +Please wait for Terraform to exit or data loss may occur. +Gracefully shutting down... +` + const planNoChanges = ` [reset][bold][green]No changes. Infrastructure is up-to-date.[reset][green] ` diff --git a/command/views/operation_test.go b/command/views/operation_test.go index 20979bfdf..600abaa68 100644 --- a/command/views/operation_test.go +++ b/command/views/operation_test.go @@ -1,10 +1,12 @@ package views import ( + "bytes" "encoding/json" "strings" "testing" + "github.com/hashicorp/terraform/addrs" "github.com/hashicorp/terraform/command/arguments" "github.com/hashicorp/terraform/internal/terminal" "github.com/hashicorp/terraform/plans" @@ -151,3 +153,319 @@ func TestOperation_planNextStepInAutomation(t *testing.T) { t.Errorf("unexpected output\ngot: %q", got) } } + +// Test all the trivial OperationJSON methods together. Y'know, for brevity. +// This test is not a realistic stream of messages. +func TestOperationJSON_logs(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + v := &OperationJSON{view: NewJSONView(NewView(streams))} + + v.Cancelled(plans.NormalMode) + v.Cancelled(plans.DestroyMode) + v.Stopping() + v.Interrupted() + v.FatalInterrupt() + + want := []map[string]interface{}{ + { + "@level": "info", + "@message": "Apply cancelled", + "@module": "terraform.ui", + "type": "log", + }, + { + "@level": "info", + "@message": "Destroy cancelled", + "@module": "terraform.ui", + "type": "log", + }, + { + "@level": "info", + "@message": "Stopping operation...", + "@module": "terraform.ui", + "type": "log", + }, + { + "@level": "info", + "@message": interrupted, + "@module": "terraform.ui", + "type": "log", + }, + { + "@level": "info", + "@message": fatalInterrupt, + "@module": "terraform.ui", + "type": "log", + }, + } + + testJSONViewOutputEquals(t, done(t).Stdout(), want) +} + +// This is a fairly circular test, but it's such a rarely executed code path +// that I think it's probably still worth having. We're not testing against +// a fixed state JSON output because this test ought not fail just because +// we upgrade state format in the future. +func TestOperationJSON_emergencyDumpState(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + v := &OperationJSON{view: NewJSONView(NewView(streams))} + + stateFile := statefile.New(nil, "foo", 1) + stateBuf := new(bytes.Buffer) + err := statefile.Write(stateFile, stateBuf) + if err != nil { + t.Fatal(err) + } + var stateJSON map[string]interface{} + err = json.Unmarshal(stateBuf.Bytes(), &stateJSON) + if err != nil { + t.Fatal(err) + } + + err = v.EmergencyDumpState(stateFile) + if err != nil { + t.Fatalf("unexpected error dumping state: %s", err) + } + + want := []map[string]interface{}{ + { + "@level": "info", + "@message": "Emergency state dump", + "@module": "terraform.ui", + "type": "log", + "state": stateJSON, + }, + } + + testJSONViewOutputEquals(t, done(t).Stdout(), want) +} + +func TestOperationJSON_planNoChanges(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + v := &OperationJSON{view: NewJSONView(NewView(streams))} + + v.PlanNoChanges() + + want := []map[string]interface{}{ + { + "@level": "info", + "@message": "Plan: 0 to add, 0 to change, 0 to destroy.", + "@module": "terraform.ui", + "type": "change_summary", + "changes": map[string]interface{}{ + "operation": "plan", + "add": float64(0), + "change": float64(0), + "remove": float64(0), + }, + }, + } + + testJSONViewOutputEquals(t, done(t).Stdout(), want) +} + +func TestOperationJSON_plan(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + v := &OperationJSON{view: NewJSONView(NewView(streams))} + + root := addrs.RootModuleInstance + vpc, diags := addrs.ParseModuleInstanceStr("module.vpc") + if len(diags) > 0 { + t.Fatal(diags.Err()) + } + boop := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_instance", Name: "boop"} + beep := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_instance", Name: "beep"} + derp := addrs.Resource{Mode: addrs.DataResourceMode, Type: "test_source", Name: "derp"} + + plan := &plans.Plan{ + Changes: &plans.Changes{ + Resources: []*plans.ResourceInstanceChangeSrc{ + { + Addr: boop.Instance(addrs.IntKey(0)).Absolute(root), + ChangeSrc: plans.ChangeSrc{Action: plans.CreateThenDelete}, + }, + { + Addr: boop.Instance(addrs.IntKey(1)).Absolute(root), + ChangeSrc: plans.ChangeSrc{Action: plans.Create}, + }, + { + Addr: boop.Instance(addrs.IntKey(0)).Absolute(vpc), + ChangeSrc: plans.ChangeSrc{Action: plans.Delete}, + }, + { + Addr: beep.Instance(addrs.NoKey).Absolute(root), + ChangeSrc: plans.ChangeSrc{Action: plans.DeleteThenCreate}, + }, + { + Addr: beep.Instance(addrs.NoKey).Absolute(vpc), + ChangeSrc: plans.ChangeSrc{Action: plans.Update}, + }, + // Data source deletion should not show up in the logs + { + Addr: derp.Instance(addrs.NoKey).Absolute(root), + ChangeSrc: plans.ChangeSrc{Action: plans.Delete}, + }, + }, + }, + } + v.Plan(plan, nil, nil) + + want := []map[string]interface{}{ + // Create-then-delete should result in replace + { + "@level": "info", + "@message": "test_instance.boop[0]: Plan to replace", + "@module": "terraform.ui", + "type": "planned_change", + "change": map[string]interface{}{ + "action": "replace", + "resource": map[string]interface{}{ + "addr": `test_instance.boop[0]`, + "implied_provider": "test", + "module": "", + "resource": `test_instance.boop[0]`, + "resource_key": float64(0), + "resource_name": "boop", + "resource_type": "test_instance", + }, + }, + }, + // Simple create + { + "@level": "info", + "@message": "test_instance.boop[1]: Plan to create", + "@module": "terraform.ui", + "type": "planned_change", + "change": map[string]interface{}{ + "action": "create", + "resource": map[string]interface{}{ + "addr": `test_instance.boop[1]`, + "implied_provider": "test", + "module": "", + "resource": `test_instance.boop[1]`, + "resource_key": float64(1), + "resource_name": "boop", + "resource_type": "test_instance", + }, + }, + }, + // Simple delete + { + "@level": "info", + "@message": "module.vpc.test_instance.boop[0]: Plan to delete", + "@module": "terraform.ui", + "type": "planned_change", + "change": map[string]interface{}{ + "action": "delete", + "resource": map[string]interface{}{ + "addr": `module.vpc.test_instance.boop[0]`, + "implied_provider": "test", + "module": "module.vpc", + "resource": `test_instance.boop[0]`, + "resource_key": float64(0), + "resource_name": "boop", + "resource_type": "test_instance", + }, + }, + }, + // Delete-then-create is also a replace + { + "@level": "info", + "@message": "test_instance.beep: Plan to replace", + "@module": "terraform.ui", + "type": "planned_change", + "change": map[string]interface{}{ + "action": "replace", + "resource": map[string]interface{}{ + "addr": `test_instance.beep`, + "implied_provider": "test", + "module": "", + "resource": `test_instance.beep`, + "resource_key": nil, + "resource_name": "beep", + "resource_type": "test_instance", + }, + }, + }, + // Simple update + { + "@level": "info", + "@message": "module.vpc.test_instance.beep: Plan to update", + "@module": "terraform.ui", + "type": "planned_change", + "change": map[string]interface{}{ + "action": "update", + "resource": map[string]interface{}{ + "addr": `module.vpc.test_instance.beep`, + "implied_provider": "test", + "module": "module.vpc", + "resource": `test_instance.beep`, + "resource_key": nil, + "resource_name": "beep", + "resource_type": "test_instance", + }, + }, + }, + // These counts are 3 add/1 change/3 destroy because the replace + // changes result in both add and destroy counts. + { + "@level": "info", + "@message": "Plan: 3 to add, 1 to change, 3 to destroy.", + "@module": "terraform.ui", + "type": "change_summary", + "changes": map[string]interface{}{ + "operation": "plan", + "add": float64(3), + "change": float64(1), + "remove": float64(3), + }, + }, + } + + testJSONViewOutputEquals(t, done(t).Stdout(), want) +} + +func TestOperationJSON_plannedChange(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + v := &OperationJSON{view: NewJSONView(NewView(streams))} + + root := addrs.RootModuleInstance + boop := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_instance", Name: "boop"} + derp := addrs.Resource{Mode: addrs.DataResourceMode, Type: "test_source", Name: "derp"} + + // Simple create + v.PlannedChange(&plans.ResourceInstanceChangeSrc{ + Addr: boop.Instance(addrs.IntKey(0)).Absolute(root), + ChangeSrc: plans.ChangeSrc{Action: plans.Create}, + }) + + // Data source deletion + v.PlannedChange(&plans.ResourceInstanceChangeSrc{ + Addr: derp.Instance(addrs.NoKey).Absolute(root), + ChangeSrc: plans.ChangeSrc{Action: plans.Delete}, + }) + + // Expect one message only, as the data source deletion should be a no-op + want := []map[string]interface{}{ + { + "@level": "info", + "@message": "test_instance.boop[0]: Plan to create", + "@module": "terraform.ui", + "type": "planned_change", + "change": map[string]interface{}{ + "action": "create", + "resource": map[string]interface{}{ + "addr": `test_instance.boop[0]`, + "implied_provider": "test", + "module": "", + "resource": `test_instance.boop[0]`, + "resource_key": float64(0), + "resource_name": "boop", + "resource_type": "test_instance", + }, + }, + }, + } + + testJSONViewOutputEquals(t, done(t).Stdout(), want) +} diff --git a/command/views/plan.go b/command/views/plan.go index 2da854767..95e578a5d 100644 --- a/command/views/plan.go +++ b/command/views/plan.go @@ -27,6 +27,10 @@ type Plan interface { // NewPlan returns an initialized Plan implementation for the given ViewType. func NewPlan(vt arguments.ViewType, runningInAutomation bool, view *View) Plan { switch vt { + case arguments.ViewJSON: + return &PlanJSON{ + view: NewJSONView(view), + } case arguments.ViewHuman: return &PlanHuman{ view: view, @@ -65,6 +69,31 @@ func (v *PlanHuman) HelpPrompt() { v.view.HelpPrompt("plan") } +// The PlanJSON implementation renders streaming JSON logs, suitable for +// integrating with other software. +type PlanJSON struct { + view *JSONView +} + +var _ Plan = (*PlanJSON)(nil) + +func (v *PlanJSON) Operation() Operation { + return &OperationJSON{view: v.view} +} + +func (v *PlanJSON) Hooks() []terraform.Hook { + return []terraform.Hook{ + newJSONHook(v.view), + } +} + +func (v *PlanJSON) Diagnostics(diags tfdiags.Diagnostics) { + v.view.Diagnostics(diags) +} + +func (v *PlanJSON) HelpPrompt() { +} + // The plan renderer is used by the Operation view (for plan and apply // commands) and the Show view (for the show command). func renderPlan(plan *plans.Plan, baseState *states.State, schemas *terraform.Schemas, view *View) { diff --git a/command/views/refresh.go b/command/views/refresh.go index 6bc3ead82..dbc316574 100644 --- a/command/views/refresh.go +++ b/command/views/refresh.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/hashicorp/terraform/command/arguments" + "github.com/hashicorp/terraform/command/views/json" "github.com/hashicorp/terraform/states" "github.com/hashicorp/terraform/terraform" "github.com/hashicorp/terraform/tfdiags" @@ -23,6 +24,10 @@ type Refresh interface { // NewRefresh returns an initialized Refresh implementation for the given ViewType. func NewRefresh(vt arguments.ViewType, runningInAutomation bool, view *View) Refresh { switch vt { + case arguments.ViewJSON: + return &RefreshJSON{ + view: NewJSONView(view), + } case arguments.ViewHuman: return &RefreshHuman{ view: view, @@ -71,3 +76,37 @@ func (v *RefreshHuman) Diagnostics(diags tfdiags.Diagnostics) { func (v *RefreshHuman) HelpPrompt() { v.view.HelpPrompt("refresh") } + +// The RefreshJSON implementation renders streaming JSON logs, suitable for +// integrating with other software. +type RefreshJSON struct { + view *JSONView +} + +var _ Refresh = (*RefreshJSON)(nil) + +func (v *RefreshJSON) Outputs(outputValues map[string]*states.OutputValue) { + outputs, diags := json.OutputsFromMap(outputValues) + if diags.HasErrors() { + v.Diagnostics(diags) + } else { + v.view.Outputs(outputs) + } +} + +func (v *RefreshJSON) Operation() Operation { + return &OperationJSON{view: v.view} +} + +func (v *RefreshJSON) Hooks() []terraform.Hook { + return []terraform.Hook{ + newJSONHook(v.view), + } +} + +func (v *RefreshJSON) Diagnostics(diags tfdiags.Diagnostics) { + v.view.Diagnostics(diags) +} + +func (v *RefreshJSON) HelpPrompt() { +} diff --git a/command/views/refresh_test.go b/command/views/refresh_test.go index 05e6ffa0d..3a409de67 100644 --- a/command/views/refresh_test.go +++ b/command/views/refresh_test.go @@ -71,3 +71,37 @@ func TestRefreshHuman_outputsEmpty(t *testing.T) { t.Errorf("output should be empty, but got: %q", got) } } + +// Basic test coverage of Outputs, since most of its functionality is tested +// elsewhere. +func TestRefreshJSON_outputs(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + v := NewRefresh(arguments.ViewJSON, false, NewView(streams)) + + v.Outputs(map[string]*states.OutputValue{ + "boop_count": {Value: cty.NumberIntVal(92)}, + "password": {Value: cty.StringVal("horse-battery").Mark("sensitive"), Sensitive: true}, + }) + + want := []map[string]interface{}{ + { + "@level": "info", + "@message": "Outputs: 2", + "@module": "terraform.ui", + "type": "outputs", + "outputs": map[string]interface{}{ + "boop_count": map[string]interface{}{ + "sensitive": false, + "value": float64(92), + "type": "number", + }, + "password": map[string]interface{}{ + "sensitive": true, + "value": "horse-battery", + "type": "string", + }, + }, + }, + } + testJSONViewOutputEquals(t, done(t).Stdout(), want) +}