cli: Add JSON logs for operations commands

This commit is contained in:
Alisdair McDiarmid 2021-02-23 10:16:09 -05:00
parent b802237e03
commit f72730a02b
28 changed files with 2499 additions and 35 deletions

View File

@ -121,6 +121,28 @@ func (b *Local) opApply(
runningOp.Result = backend.OperationFailure runningOp.Result = backend.OperationFailure
return 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)
}
} }
} }

View File

@ -3,9 +3,11 @@ package command
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"os" "os"
"path"
"path/filepath" "path/filepath"
"reflect" "reflect"
"strings" "strings"
@ -19,12 +21,14 @@ import (
"github.com/zclconf/go-cty/cty" "github.com/zclconf/go-cty/cty"
"github.com/hashicorp/terraform/addrs" "github.com/hashicorp/terraform/addrs"
"github.com/hashicorp/terraform/command/views"
"github.com/hashicorp/terraform/configs/configschema" "github.com/hashicorp/terraform/configs/configschema"
"github.com/hashicorp/terraform/plans" "github.com/hashicorp/terraform/plans"
"github.com/hashicorp/terraform/providers" "github.com/hashicorp/terraform/providers"
"github.com/hashicorp/terraform/states" "github.com/hashicorp/terraform/states"
"github.com/hashicorp/terraform/states/statemgr" "github.com/hashicorp/terraform/states/statemgr"
"github.com/hashicorp/terraform/terraform" "github.com/hashicorp/terraform/terraform"
tfversion "github.com/hashicorp/terraform/version"
) )
func TestApply(t *testing.T) { 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 // applyFixtureSchema returns a schema suitable for processing the
// configuration in testdata/apply . This schema should be // configuration in testdata/apply . This schema should be
// assigned to a mock provider named "test". // assigned to a mock provider named "test".

View File

@ -43,6 +43,9 @@ func ParseApply(args []string) (*Apply, tfdiags.Diagnostics) {
cmdFlags.BoolVar(&apply.AutoApprove, "auto-approve", false, "auto-approve") cmdFlags.BoolVar(&apply.AutoApprove, "auto-approve", false, "auto-approve")
cmdFlags.BoolVar(&apply.InputEnabled, "input", true, "input") cmdFlags.BoolVar(&apply.InputEnabled, "input", true, "input")
var json bool
cmdFlags.BoolVar(&json, "json", false, "json")
if err := cmdFlags.Parse(args); err != nil { if err := cmdFlags.Parse(args); err != nil {
diags = diags.Append(tfdiags.Sourceless( diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error, 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()) diags = diags.Append(apply.Operation.Parse())
switch { switch {
case json:
apply.ViewType = ViewJSON
default: default:
apply.ViewType = ViewHuman apply.ViewType = ViewHuman
} }

View File

@ -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{}) 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) { func TestParseApply_invalid(t *testing.T) {
got, diags := ParseApply([]string{"-frob"}) got, diags := ParseApply([]string{"-frob"})
if len(diags) == 0 { if len(diags) == 0 {

View File

@ -42,6 +42,9 @@ func ParsePlan(args []string) (*Plan, tfdiags.Diagnostics) {
cmdFlags.BoolVar(&plan.InputEnabled, "input", true, "input") cmdFlags.BoolVar(&plan.InputEnabled, "input", true, "input")
cmdFlags.StringVar(&plan.OutPath, "out", "", "out") cmdFlags.StringVar(&plan.OutPath, "out", "", "out")
var json bool
cmdFlags.BoolVar(&json, "json", false, "json")
if err := cmdFlags.Parse(args); err != nil { if err := cmdFlags.Parse(args); err != nil {
diags = diags.Append(tfdiags.Sourceless( diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error, tfdiags.Error,
@ -62,7 +65,14 @@ func ParsePlan(args []string) (*Plan, tfdiags.Diagnostics) {
diags = diags.Append(plan.Operation.Parse()) diags = diags.Append(plan.Operation.Parse())
// JSON view currently does not support input, so we disable it here
if json {
plan.InputEnabled = false
}
switch { switch {
case json:
plan.ViewType = ViewJSON
default: default:
plan.ViewType = ViewHuman plan.ViewType = ViewHuman
} }

View File

@ -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{}) cmpOpts := cmpopts.IgnoreUnexported(Operation{}, Vars{}, State{})

View File

@ -33,6 +33,9 @@ func ParseRefresh(args []string) (*Refresh, tfdiags.Diagnostics) {
cmdFlags := extendedFlagSet("refresh", refresh.State, refresh.Operation, refresh.Vars) cmdFlags := extendedFlagSet("refresh", refresh.State, refresh.Operation, refresh.Vars)
cmdFlags.BoolVar(&refresh.InputEnabled, "input", true, "input") cmdFlags.BoolVar(&refresh.InputEnabled, "input", true, "input")
var json bool
cmdFlags.BoolVar(&json, "json", false, "json")
if err := cmdFlags.Parse(args); err != nil { if err := cmdFlags.Parse(args); err != nil {
diags = diags.Append(tfdiags.Sourceless( diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error, tfdiags.Error,
@ -52,7 +55,14 @@ func ParseRefresh(args []string) (*Refresh, tfdiags.Diagnostics) {
diags = diags.Append(refresh.Operation.Parse()) diags = diags.Append(refresh.Operation.Parse())
// JSON view currently does not support input, so we disable it here
if json {
refresh.InputEnabled = false
}
switch { switch {
case json:
refresh.ViewType = ViewJSON
default: default:
refresh.ViewType = ViewHuman refresh.ViewType = ViewHuman
} }

View File

@ -20,13 +20,20 @@ func TestParseRefresh_basicValid(t *testing.T) {
ViewType: ViewHuman, ViewType: ViewHuman,
}, },
}, },
"input=flase": { "input=false": {
[]string{"-input=false"}, []string{"-input=false"},
&Refresh{ &Refresh{
InputEnabled: false, InputEnabled: false,
ViewType: ViewHuman, ViewType: ViewHuman,
}, },
}, },
"JSON view disables input": {
[]string{"-json"},
&Refresh{
InputEnabled: false,
ViewType: ViewJSON,
},
},
} }
for name, tc := range testCases { for name, tc := range testCases {

View File

@ -11,3 +11,18 @@ const (
ViewJSON ViewType = 'J' ViewJSON ViewType = 'J'
ViewRaw ViewType = 'R' 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"
}
}

6
command/testdata/apply/output.jsonlog vendored Normal file
View File

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

View File

@ -5,6 +5,7 @@ import (
"github.com/hashicorp/terraform/command/arguments" "github.com/hashicorp/terraform/command/arguments"
"github.com/hashicorp/terraform/command/format" "github.com/hashicorp/terraform/command/format"
"github.com/hashicorp/terraform/command/views/json"
"github.com/hashicorp/terraform/states" "github.com/hashicorp/terraform/states"
"github.com/hashicorp/terraform/terraform" "github.com/hashicorp/terraform/terraform"
"github.com/hashicorp/terraform/tfdiags" "github.com/hashicorp/terraform/tfdiags"
@ -25,6 +26,12 @@ type Apply interface {
// NewApply returns an initialized Apply implementation for the given ViewType. // NewApply returns an initialized Apply implementation for the given ViewType.
func NewApply(vt arguments.ViewType, destroy bool, runningInAutomation bool, view *View) Apply { func NewApply(vt arguments.ViewType, destroy bool, runningInAutomation bool, view *View) Apply {
switch vt { switch vt {
case arguments.ViewJSON:
return &ApplyJSON{
view: NewJSONView(view),
destroy: destroy,
countHook: &countHook{},
}
case arguments.ViewHuman: case arguments.ViewHuman:
return &ApplyHuman{ return &ApplyHuman{
view: view, 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." 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() {
}

View File

@ -1,6 +1,7 @@
package views package views
import ( import (
"fmt"
"strings" "strings"
"testing" "testing"
@ -96,7 +97,7 @@ func TestApplyHuman_help(t *testing.T) {
} }
// Hooks and ResourceCount are tangled up and easiest to test together. // 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 { testCases := map[string]struct {
destroy bool destroy bool
want string want string
@ -111,10 +112,15 @@ 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 { for name, tc := range testCases {
t.Run(name, func(t *testing.T) { for _, viewType := range views {
t.Run(fmt.Sprintf("%s (%s view)", name, viewType), func(t *testing.T) {
streams, done := terminal.StreamsForTesting(t) streams, done := terminal.StreamsForTesting(t)
v := NewApply(arguments.ViewHuman, tc.destroy, false, NewView(streams)) v := NewApply(viewType, tc.destroy, false, NewView(streams))
hooks := v.Hooks() hooks := v.Hooks()
var count *countHook var count *countHook
@ -140,6 +146,7 @@ func TestApplyHuman_resourceCount(t *testing.T) {
}) })
} }
} }
}
func TestApplyHuman_resourceCountStatePath(t *testing.T) { func TestApplyHuman_resourceCountStatePath(t *testing.T) {
testCases := map[string]struct { testCases := map[string]struct {
@ -171,8 +178,8 @@ func TestApplyHuman_resourceCountStatePath(t *testing.T) {
wantContains: true, wantContains: true,
}, },
"changed": { "changed": {
added: 5, added: 0,
changed: 0, changed: 5,
removed: 0, removed: 0,
statePath: "foo.tfstate", statePath: "foo.tfstate",
wantContains: true, 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)
}

156
command/views/hook_json.go Normal file
View File

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

View File

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

View File

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

View File

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

364
command/views/json/hook.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

120
command/views/json_view.go Normal file
View File

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

View File

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

View File

@ -5,8 +5,10 @@ import (
"fmt" "fmt"
"strings" "strings"
"github.com/hashicorp/terraform/addrs"
"github.com/hashicorp/terraform/command/arguments" "github.com/hashicorp/terraform/command/arguments"
"github.com/hashicorp/terraform/command/format" "github.com/hashicorp/terraform/command/format"
"github.com/hashicorp/terraform/command/views/json"
"github.com/hashicorp/terraform/plans" "github.com/hashicorp/terraform/plans"
"github.com/hashicorp/terraform/states" "github.com/hashicorp/terraform/states"
"github.com/hashicorp/terraform/states/statefile" "github.com/hashicorp/terraform/states/statefile"
@ -22,6 +24,7 @@ type Operation interface {
EmergencyDumpState(stateFile *statefile.File) error EmergencyDumpState(stateFile *statefile.File) error
PlannedChange(change *plans.ResourceInstanceChangeSrc)
PlanNoChanges() PlanNoChanges()
Plan(plan *plans.Plan, baseState *states.State, schemas *terraform.Schemas) Plan(plan *plans.Plan, baseState *states.State, schemas *terraform.Schemas)
PlanNextStep(planPath string) PlanNextStep(planPath string)
@ -61,16 +64,6 @@ func (v *OperationHuman) FatalInterrupt() {
v.view.streams.Eprintln(format.WordWrap(fatalInterrupt, v.view.errorColumns())) 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() { func (v *OperationHuman) Stopping() {
v.view.streams.Println("Stopping operation...") 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) 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 // 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. // automation tool which is presumed to provide its own UI for further actions.
func (v *OperationHuman) PlanNextStep(planPath string) { func (v *OperationHuman) PlanNextStep(planPath string) {
@ -127,6 +123,111 @@ func (v *OperationHuman) Diagnostics(diags tfdiags.Diagnostics) {
v.view.Diagnostics(diags) 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 = ` const planNoChanges = `
[reset][bold][green]No changes. Infrastructure is up-to-date.[reset][green] [reset][bold][green]No changes. Infrastructure is up-to-date.[reset][green]
` `

View File

@ -1,10 +1,12 @@
package views package views
import ( import (
"bytes"
"encoding/json" "encoding/json"
"strings" "strings"
"testing" "testing"
"github.com/hashicorp/terraform/addrs"
"github.com/hashicorp/terraform/command/arguments" "github.com/hashicorp/terraform/command/arguments"
"github.com/hashicorp/terraform/internal/terminal" "github.com/hashicorp/terraform/internal/terminal"
"github.com/hashicorp/terraform/plans" "github.com/hashicorp/terraform/plans"
@ -151,3 +153,319 @@ func TestOperation_planNextStepInAutomation(t *testing.T) {
t.Errorf("unexpected output\ngot: %q", got) 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)
}

View File

@ -27,6 +27,10 @@ type Plan interface {
// NewPlan returns an initialized Plan implementation for the given ViewType. // NewPlan returns an initialized Plan implementation for the given ViewType.
func NewPlan(vt arguments.ViewType, runningInAutomation bool, view *View) Plan { func NewPlan(vt arguments.ViewType, runningInAutomation bool, view *View) Plan {
switch vt { switch vt {
case arguments.ViewJSON:
return &PlanJSON{
view: NewJSONView(view),
}
case arguments.ViewHuman: case arguments.ViewHuman:
return &PlanHuman{ return &PlanHuman{
view: view, view: view,
@ -65,6 +69,31 @@ func (v *PlanHuman) HelpPrompt() {
v.view.HelpPrompt("plan") 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 // The plan renderer is used by the Operation view (for plan and apply
// commands) and the Show view (for the show command). // commands) and the Show view (for the show command).
func renderPlan(plan *plans.Plan, baseState *states.State, schemas *terraform.Schemas, view *View) { func renderPlan(plan *plans.Plan, baseState *states.State, schemas *terraform.Schemas, view *View) {

View File

@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"github.com/hashicorp/terraform/command/arguments" "github.com/hashicorp/terraform/command/arguments"
"github.com/hashicorp/terraform/command/views/json"
"github.com/hashicorp/terraform/states" "github.com/hashicorp/terraform/states"
"github.com/hashicorp/terraform/terraform" "github.com/hashicorp/terraform/terraform"
"github.com/hashicorp/terraform/tfdiags" "github.com/hashicorp/terraform/tfdiags"
@ -23,6 +24,10 @@ type Refresh interface {
// NewRefresh returns an initialized Refresh implementation for the given ViewType. // NewRefresh returns an initialized Refresh implementation for the given ViewType.
func NewRefresh(vt arguments.ViewType, runningInAutomation bool, view *View) Refresh { func NewRefresh(vt arguments.ViewType, runningInAutomation bool, view *View) Refresh {
switch vt { switch vt {
case arguments.ViewJSON:
return &RefreshJSON{
view: NewJSONView(view),
}
case arguments.ViewHuman: case arguments.ViewHuman:
return &RefreshHuman{ return &RefreshHuman{
view: view, view: view,
@ -71,3 +76,37 @@ func (v *RefreshHuman) Diagnostics(diags tfdiags.Diagnostics) {
func (v *RefreshHuman) HelpPrompt() { func (v *RefreshHuman) HelpPrompt() {
v.view.HelpPrompt("refresh") 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() {
}

View File

@ -71,3 +71,37 @@ func TestRefreshHuman_outputsEmpty(t *testing.T) {
t.Errorf("output should be empty, but got: %q", got) 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)
}