command/format: a package for formatting plans/state for output

This commit is contained in:
Mitchell Hashimoto 2017-01-18 20:49:42 -08:00
parent 251e5c6f87
commit 0a0842a7d9
No known key found for this signature in database
GPG Key ID: 744E147AA52F5B0A
4 changed files with 674 additions and 0 deletions

8
command/format/format.go Normal file
View File

@ -0,0 +1,8 @@
// Package format contains helpers for formatting various Terraform
// structures for human-readabout output.
//
// This package is used by the official Terraform CLI in formatting any
// output and is exported to encourage non-official frontends to mimic the
// output formatting as much as possible so that text formats of Terraform
// structures have a consistent look and feel.
package format

231
command/format/plan.go Normal file
View File

@ -0,0 +1,231 @@
package format
import (
"bytes"
"fmt"
"sort"
"strings"
"github.com/hashicorp/terraform/terraform"
"github.com/mitchellh/colorstring"
)
// PlanOpts are the options for formatting a plan.
type PlanOpts struct {
// Plan is the plan to format. This is required.
Plan *terraform.Plan
// Color is the colorizer. This is optional.
Color *colorstring.Colorize
// ModuleDepth is the depth of the modules to expand. By default this
// is zero which will not expand modules at all.
ModuleDepth int
}
// Plan takes a plan and returns a
func Plan(opts *PlanOpts) string {
p := opts.Plan
if p.Diff == nil || p.Diff.Empty() {
return "This plan does nothing."
}
if opts.Color == nil {
opts.Color = &colorstring.Colorize{
Colors: colorstring.DefaultColors,
Reset: false,
}
}
buf := new(bytes.Buffer)
for _, m := range p.Diff.Modules {
if len(m.Path)-1 <= opts.ModuleDepth || opts.ModuleDepth == -1 {
formatPlanModuleExpand(buf, m, opts)
} else {
formatPlanModuleSingle(buf, m, opts)
}
}
return strings.TrimSpace(buf.String())
}
// formatPlanModuleExpand will output the given module and all of its
// resources.
func formatPlanModuleExpand(
buf *bytes.Buffer, m *terraform.ModuleDiff, opts *PlanOpts) {
// Ignore empty diffs
if m.Empty() {
return
}
var moduleName string
if !m.IsRoot() {
moduleName = fmt.Sprintf("module.%s", strings.Join(m.Path[1:], "."))
}
// We want to output the resources in sorted order to make things
// easier to scan through, so get all the resource names and sort them.
names := make([]string, 0, len(m.Resources))
for name, _ := range m.Resources {
names = append(names, name)
}
sort.Strings(names)
// Go through each sorted name and start building the output
for _, name := range names {
rdiff := m.Resources[name]
if rdiff.Empty() {
continue
}
dataSource := strings.HasPrefix(name, "data.")
if moduleName != "" {
name = moduleName + "." + name
}
// Determine the color for the text (green for adding, yellow
// for change, red for delete), and symbol, and output the
// resource header.
color := "yellow"
symbol := "~"
oldValues := true
switch rdiff.ChangeType() {
case terraform.DiffDestroyCreate:
color = "green"
symbol = "-/+"
case terraform.DiffCreate:
color = "green"
symbol = "+"
oldValues = false
// If we're "creating" a data resource then we'll present it
// to the user as a "read" operation, so it's clear that this
// operation won't change anything outside of the Terraform state.
// Unfortunately by the time we get here we only have the name
// to work with, so we need to cheat and exploit knowledge of the
// naming scheme for data resources.
if dataSource {
symbol = "<="
color = "cyan"
}
case terraform.DiffDestroy:
color = "red"
symbol = "-"
}
var extraAttr []string
if rdiff.DestroyTainted {
extraAttr = append(extraAttr, "tainted")
}
if rdiff.DestroyDeposed {
extraAttr = append(extraAttr, "deposed")
}
var extraStr string
if len(extraAttr) > 0 {
extraStr = fmt.Sprintf(" (%s)", strings.Join(extraAttr, ", "))
}
buf.WriteString(opts.Color.Color(fmt.Sprintf(
"[%s]%s %s%s\n",
color, symbol, name, extraStr)))
// Get all the attributes that are changing, and sort them. Also
// determine the longest key so that we can align them all.
keyLen := 0
keys := make([]string, 0, len(rdiff.Attributes))
for key, _ := range rdiff.Attributes {
// Skip the ID since we do that specially
if key == "id" {
continue
}
keys = append(keys, key)
if len(key) > keyLen {
keyLen = len(key)
}
}
sort.Strings(keys)
// Go through and output each attribute
for _, attrK := range keys {
attrDiff := rdiff.Attributes[attrK]
v := attrDiff.New
if v == "" && attrDiff.NewComputed {
v = "<computed>"
}
if attrDiff.Sensitive {
v = "<sensitive>"
}
updateMsg := ""
if attrDiff.RequiresNew && rdiff.Destroy {
updateMsg = opts.Color.Color(" [red](forces new resource)")
} else if attrDiff.Sensitive && oldValues {
updateMsg = opts.Color.Color(" [yellow](attribute changed)")
}
if oldValues {
var u string
if attrDiff.Sensitive {
u = "<sensitive>"
} else {
u = attrDiff.Old
}
buf.WriteString(fmt.Sprintf(
" %s:%s %#v => %#v%s\n",
attrK,
strings.Repeat(" ", keyLen-len(attrK)),
u,
v,
updateMsg))
} else {
buf.WriteString(fmt.Sprintf(
" %s:%s %#v%s\n",
attrK,
strings.Repeat(" ", keyLen-len(attrK)),
v,
updateMsg))
}
}
// Write the reset color so we don't overload the user's terminal
buf.WriteString(opts.Color.Color("[reset]\n"))
}
}
// formatPlanModuleSingle will output the given module and all of its
// resources.
func formatPlanModuleSingle(
buf *bytes.Buffer, m *terraform.ModuleDiff, opts *PlanOpts) {
// Ignore empty diffs
if m.Empty() {
return
}
moduleName := fmt.Sprintf("module.%s", strings.Join(m.Path[1:], "."))
// Determine the color for the text (green for adding, yellow
// for change, red for delete), and symbol, and output the
// resource header.
color := "yellow"
symbol := "~"
switch m.ChangeType() {
case terraform.DiffCreate:
color = "green"
symbol = "+"
case terraform.DiffDestroy:
color = "red"
symbol = "-"
}
buf.WriteString(opts.Color.Color(fmt.Sprintf(
"[%s]%s %s\n",
color, symbol, moduleName)))
buf.WriteString(fmt.Sprintf(
" %d resource(s)",
len(m.Resources)))
buf.WriteString(opts.Color.Color("[reset]\n"))
}

170
command/format/plan_test.go Normal file
View File

@ -0,0 +1,170 @@
package format
import (
"strings"
"testing"
"github.com/hashicorp/terraform/terraform"
"github.com/mitchellh/colorstring"
)
// Test that a root level data source gets a special plan output on create
func TestPlan_destroyDeposed(t *testing.T) {
plan := &terraform.Plan{
Diff: &terraform.Diff{
Modules: []*terraform.ModuleDiff{
&terraform.ModuleDiff{
Path: []string{"root"},
Resources: map[string]*terraform.InstanceDiff{
"aws_instance.foo": &terraform.InstanceDiff{
DestroyDeposed: true,
},
},
},
},
},
}
opts := &PlanOpts{
Plan: plan,
Color: &colorstring.Colorize{
Colors: colorstring.DefaultColors,
Disable: true,
},
ModuleDepth: 1,
}
actual := Plan(opts)
expected := strings.TrimSpace(`
- aws_instance.foo (deposed)
`)
if actual != expected {
t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, actual)
}
}
// Test that computed fields with an interpolation string get displayed
func TestPlan_displayInterpolations(t *testing.T) {
plan := &terraform.Plan{
Diff: &terraform.Diff{
Modules: []*terraform.ModuleDiff{
&terraform.ModuleDiff{
Path: []string{"root"},
Resources: map[string]*terraform.InstanceDiff{
"aws_instance.foo": &terraform.InstanceDiff{
Attributes: map[string]*terraform.ResourceAttrDiff{
"computed_field": &terraform.ResourceAttrDiff{
New: "${aws_instance.other.id}",
NewComputed: true,
},
},
},
},
},
},
},
}
opts := &PlanOpts{
Plan: plan,
Color: &colorstring.Colorize{
Colors: colorstring.DefaultColors,
Disable: true,
},
ModuleDepth: 1,
}
out := Plan(opts)
lines := strings.Split(out, "\n")
if len(lines) != 2 {
t.Fatal("expected 2 lines of output, got:\n", out)
}
actual := strings.TrimSpace(lines[1])
expected := `computed_field: "" => "${aws_instance.other.id}"`
if actual != expected {
t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, actual)
}
}
// Test that a root level data source gets a special plan output on create
func TestPlan_rootDataSource(t *testing.T) {
plan := &terraform.Plan{
Diff: &terraform.Diff{
Modules: []*terraform.ModuleDiff{
&terraform.ModuleDiff{
Path: []string{"root"},
Resources: map[string]*terraform.InstanceDiff{
"data.type.name": &terraform.InstanceDiff{
Attributes: map[string]*terraform.ResourceAttrDiff{
"A": &terraform.ResourceAttrDiff{
New: "B",
RequiresNew: true,
},
},
},
},
},
},
},
}
opts := &PlanOpts{
Plan: plan,
Color: &colorstring.Colorize{
Colors: colorstring.DefaultColors,
Disable: true,
},
ModuleDepth: 1,
}
actual := Plan(opts)
expected := strings.TrimSpace(`
<= data.type.name
A: "B"
`)
if actual != expected {
t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, actual)
}
}
// Test that data sources nested in modules get the same plan output
func TestPlan_nestedDataSource(t *testing.T) {
plan := &terraform.Plan{
Diff: &terraform.Diff{
Modules: []*terraform.ModuleDiff{
&terraform.ModuleDiff{
Path: []string{"root", "nested"},
Resources: map[string]*terraform.InstanceDiff{
"data.type.name": &terraform.InstanceDiff{
Attributes: map[string]*terraform.ResourceAttrDiff{
"A": &terraform.ResourceAttrDiff{
New: "B",
RequiresNew: true,
},
},
},
},
},
},
},
}
opts := &PlanOpts{
Plan: plan,
Color: &colorstring.Colorize{
Colors: colorstring.DefaultColors,
Disable: true,
},
ModuleDepth: 2,
}
actual := Plan(opts)
expected := strings.TrimSpace(`
<= module.nested.data.type.name
A: "B"
`)
if actual != expected {
t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, actual)
}
}

265
command/format/state.go Normal file
View File

@ -0,0 +1,265 @@
package format
import (
"bytes"
"fmt"
"sort"
"strings"
"github.com/hashicorp/terraform/terraform"
"github.com/mitchellh/colorstring"
)
// StateOpts are the options for formatting a state.
type StateOpts struct {
// State is the state to format. This is required.
State *terraform.State
// Color is the colorizer. This is optional.
Color *colorstring.Colorize
// ModuleDepth is the depth of the modules to expand. By default this
// is zero which will not expand modules at all.
ModuleDepth int
}
// State takes a state and returns a string
func State(opts *StateOpts) string {
if opts.Color == nil {
panic("colorize not given")
}
s := opts.State
if len(s.Modules) == 0 {
return "The state file is empty. No resources are represented."
}
var buf bytes.Buffer
buf.WriteString("[reset]")
// Format all the modules
for _, m := range s.Modules {
if len(m.Path)-1 <= opts.ModuleDepth || opts.ModuleDepth == -1 {
formatStateModuleExpand(&buf, m, opts)
} else {
formatStateModuleSingle(&buf, m, opts)
}
}
// Write the outputs for the root module
m := s.RootModule()
if len(m.Outputs) > 0 {
buf.WriteString("\nOutputs:\n\n")
// Sort the outputs
ks := make([]string, 0, len(m.Outputs))
for k, _ := range m.Outputs {
ks = append(ks, k)
}
sort.Strings(ks)
// Output each output k/v pair
for _, k := range ks {
v := m.Outputs[k]
switch output := v.Value.(type) {
case string:
buf.WriteString(fmt.Sprintf("%s = %s", k, output))
buf.WriteString("\n")
case []interface{}:
buf.WriteString(formatListOutput("", k, output))
buf.WriteString("\n")
case map[string]interface{}:
buf.WriteString(formatMapOutput("", k, output))
buf.WriteString("\n")
}
}
}
return opts.Color.Color(strings.TrimSpace(buf.String()))
}
func formatStateModuleExpand(
buf *bytes.Buffer, m *terraform.ModuleState, opts *StateOpts) {
var moduleName string
if !m.IsRoot() {
moduleName = fmt.Sprintf("module.%s", strings.Join(m.Path[1:], "."))
}
// First get the names of all the resources so we can show them
// in alphabetical order.
names := make([]string, 0, len(m.Resources))
for name, _ := range m.Resources {
names = append(names, name)
}
sort.Strings(names)
// Go through each resource and begin building up the output.
for _, k := range names {
name := k
if moduleName != "" {
name = moduleName + "." + name
}
rs := m.Resources[k]
is := rs.Primary
var id string
if is != nil {
id = is.ID
}
if id == "" {
id = "<not created>"
}
taintStr := ""
if rs.Primary != nil && rs.Primary.Tainted {
taintStr = " (tainted)"
}
buf.WriteString(fmt.Sprintf("%s:%s\n", name, taintStr))
buf.WriteString(fmt.Sprintf(" id = %s\n", id))
if is != nil {
// Sort the attributes
attrKeys := make([]string, 0, len(is.Attributes))
for ak, _ := range is.Attributes {
// Skip the id attribute since we just show the id directly
if ak == "id" {
continue
}
attrKeys = append(attrKeys, ak)
}
sort.Strings(attrKeys)
// Output each attribute
for _, ak := range attrKeys {
av := is.Attributes[ak]
buf.WriteString(fmt.Sprintf(" %s = %s\n", ak, av))
}
}
}
buf.WriteString("[reset]\n")
}
func formatStateModuleSingle(
buf *bytes.Buffer, m *terraform.ModuleState, opts *StateOpts) {
// Header with the module name
buf.WriteString(fmt.Sprintf("module.%s\n", strings.Join(m.Path[1:], ".")))
// Now just write how many resources are in here.
buf.WriteString(fmt.Sprintf(" %d resource(s)\n", len(m.Resources)))
}
func formatNestedList(indent string, outputList []interface{}) string {
outputBuf := new(bytes.Buffer)
outputBuf.WriteString(fmt.Sprintf("%s[", indent))
lastIdx := len(outputList) - 1
for i, value := range outputList {
outputBuf.WriteString(fmt.Sprintf("\n%s%s%s", indent, " ", value))
if i != lastIdx {
outputBuf.WriteString(",")
}
}
outputBuf.WriteString(fmt.Sprintf("\n%s]", indent))
return strings.TrimPrefix(outputBuf.String(), "\n")
}
func formatListOutput(indent, outputName string, outputList []interface{}) string {
keyIndent := ""
outputBuf := new(bytes.Buffer)
if outputName != "" {
outputBuf.WriteString(fmt.Sprintf("%s%s = [", indent, outputName))
keyIndent = " "
}
lastIdx := len(outputList) - 1
for i, value := range outputList {
switch typedValue := value.(type) {
case string:
outputBuf.WriteString(fmt.Sprintf("\n%s%s%s", indent, keyIndent, value))
case []interface{}:
outputBuf.WriteString(fmt.Sprintf("\n%s%s", indent,
formatNestedList(indent+keyIndent, typedValue)))
case map[string]interface{}:
outputBuf.WriteString(fmt.Sprintf("\n%s%s", indent,
formatNestedMap(indent+keyIndent, typedValue)))
}
if lastIdx != i {
outputBuf.WriteString(",")
}
}
if outputName != "" {
if len(outputList) > 0 {
outputBuf.WriteString(fmt.Sprintf("\n%s]", indent))
} else {
outputBuf.WriteString("]")
}
}
return strings.TrimPrefix(outputBuf.String(), "\n")
}
func formatNestedMap(indent string, outputMap map[string]interface{}) string {
ks := make([]string, 0, len(outputMap))
for k, _ := range outputMap {
ks = append(ks, k)
}
sort.Strings(ks)
outputBuf := new(bytes.Buffer)
outputBuf.WriteString(fmt.Sprintf("%s{", indent))
lastIdx := len(outputMap) - 1
for i, k := range ks {
v := outputMap[k]
outputBuf.WriteString(fmt.Sprintf("\n%s%s = %v", indent+" ", k, v))
if lastIdx != i {
outputBuf.WriteString(",")
}
}
outputBuf.WriteString(fmt.Sprintf("\n%s}", indent))
return strings.TrimPrefix(outputBuf.String(), "\n")
}
func formatMapOutput(indent, outputName string, outputMap map[string]interface{}) string {
ks := make([]string, 0, len(outputMap))
for k, _ := range outputMap {
ks = append(ks, k)
}
sort.Strings(ks)
keyIndent := ""
outputBuf := new(bytes.Buffer)
if outputName != "" {
outputBuf.WriteString(fmt.Sprintf("%s%s = {", indent, outputName))
keyIndent = " "
}
for _, k := range ks {
v := outputMap[k]
outputBuf.WriteString(fmt.Sprintf("\n%s%s%s = %v", indent, keyIndent, k, v))
}
if outputName != "" {
if len(outputMap) > 0 {
outputBuf.WriteString(fmt.Sprintf("\n%s}", indent))
} else {
outputBuf.WriteString("}")
}
}
return strings.TrimPrefix(outputBuf.String(), "\n")
}