Add rendering functionality for primitives to the structured renderer (#32373)

* prep for processing the structured run output

* undo unwanted change to a json key

* Add skeleton functions and API for refactored renderer

* goimports

* Fix documentation of the RenderOpts struct

* Add rendering functionality for primitives to the structured renderer

* add test case for override

* goimports
This commit is contained in:
Liam Cervante 2023-01-09 11:24:01 +01:00 committed by GitHub
parent aff7d360e1
commit 71daef058f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 428 additions and 13 deletions

View File

@ -55,3 +55,21 @@ func (change Change) Render(indent int, opts RenderOpts) string {
func (change Change) Warnings(indent int) []string {
return change.renderer.Warnings(change, indent)
}
// nullSuffix returns the `-> null` suffix if the change is a delete action, and
// it has not been overridden.
func (change Change) nullSuffix(override bool) string {
if !override && change.action == plans.Delete {
return " [dark_gray]-> null[reset]"
}
return ""
}
// forcesReplacement returns the `# forces replacement` suffix if this change is
// driving the entire resource to be replaced.
func (change Change) forcesReplacement() string {
if change.replace {
return " [red]# forces replacement[reset]"
}
return ""
}

View File

@ -0,0 +1,48 @@
package change
import (
"fmt"
"github.com/hashicorp/terraform/internal/plans"
)
func Primitive(before, after *string) Renderer {
return primitiveRenderer{
before: before,
after: after,
}
}
type primitiveRenderer struct {
NoWarningsRenderer
before *string
after *string
}
func (render primitiveRenderer) Render(result Change, indent int, opts RenderOpts) string {
var beforeValue, afterValue string
if render.before != nil {
beforeValue = *render.before
} else {
beforeValue = "[dark_gray]null[reset]"
}
if render.after != nil {
afterValue = *render.after
} else {
afterValue = "[dark_gray]null[reset]"
}
switch result.action {
case plans.Create:
return fmt.Sprintf("%s%s", afterValue, result.forcesReplacement())
case plans.Delete:
return fmt.Sprintf("%s%s%s", beforeValue, result.nullSuffix(opts.overrideNullSuffix), result.forcesReplacement())
case plans.NoOp:
return fmt.Sprintf("%s%s", beforeValue, result.forcesReplacement())
default:
return fmt.Sprintf("%s [yellow]->[reset] %s%s", beforeValue, afterValue, result.forcesReplacement())
}
}

View File

@ -22,13 +22,22 @@ func (render NoWarningsRenderer) Warnings(change Change, indent int) []string {
// RenderOpts contains options that can control how the Renderer.Render function
// will render.
type RenderOpts struct {
// overrideNullSuffix tells the Renderer not to display the `-> null` suffix
// that is normally displayed when an element, attribute, or block is
// deleted.
//
// For now, we haven't implemented any of the Renderer functionality, so we have
// no options currently.
type RenderOpts struct{}
// The presence of this suffix is decided by the parent changes of a given
// change, as such we provide this as an option instead of trying to
// calculate it inside a specific renderer.
overrideNullSuffix bool
}
// Clone returns a new RenderOpts object, that matches the original but can be
// edited without changing the original.
func (opts RenderOpts) Clone() RenderOpts {
return RenderOpts{}
return RenderOpts{
overrideNullSuffix: opts.overrideNullSuffix,
}
}

View File

@ -0,0 +1,86 @@
package change
import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/mitchellh/colorstring"
"github.com/hashicorp/terraform/internal/plans"
)
func TestRenderers(t *testing.T) {
strptr := func(in string) *string {
return &in
}
colorize := colorstring.Colorize{
Colors: colorstring.DefaultColors,
Disable: true,
}
tcs := map[string]struct {
change Change
expected string
opts RenderOpts
}{
"primitive_create": {
change: Change{
renderer: Primitive(nil, strptr("1")),
action: plans.Create,
replace: false,
},
expected: "1",
},
"primitive_delete": {
change: Change{
renderer: Primitive(strptr("1"), nil),
action: plans.Delete,
replace: false,
},
expected: "1 -> null",
},
"primitive_delete_override": {
change: Change{
renderer: Primitive(strptr("1"), nil),
action: plans.Delete,
replace: false,
},
opts: RenderOpts{overrideNullSuffix: true},
expected: "1",
},
"primitive_update_to_null": {
change: Change{
renderer: Primitive(strptr("1"), nil),
action: plans.Update,
replace: false,
},
expected: "1 -> null",
},
"primitive_update_from_null": {
change: Change{
renderer: Primitive(nil, strptr("1")),
action: plans.Update,
replace: false,
},
expected: "null -> 1",
},
"primitive_update": {
change: Change{
renderer: Primitive(strptr("0"), strptr("1")),
action: plans.Update,
replace: false,
},
expected: "0 -> 1",
},
}
for name, tc := range tcs {
t.Run(name, func(t *testing.T) {
actual := colorize.Color(tc.change.Render(0, tc.opts))
if diff := cmp.Diff(tc.expected, actual); len(diff) > 0 {
t.Fatalf("\nexpected:\n%s\nactual:\n%s\ndiff:\n%s\n", tc.expected, actual, diff)
}
})
}
}

View File

@ -0,0 +1,35 @@
package change
import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/terraform/internal/plans"
)
type ValidateChangeFunc func(t *testing.T, change Change)
func ValidateChange(t *testing.T, f ValidateChangeFunc, change Change, expectedAction plans.Action, expectedReplace bool) {
if change.replace != expectedReplace || change.action != expectedAction {
t.Fatalf("\nreplace:\n\texpected:%t\n\tactual:%t\naction:\n\texpected:%s\n\tactual:%s", expectedReplace, change.replace, expectedAction, change.action)
}
f(t, change)
}
func ValidatePrimitive(before, after *string) ValidateChangeFunc {
return func(t *testing.T, change Change) {
primitive, ok := change.renderer.(primitiveRenderer)
if !ok {
t.Fatalf("invalid renderer type: %T", change.renderer)
}
beforeDiff := cmp.Diff(primitive.before, before)
afterDiff := cmp.Diff(primitive.after, after)
if len(beforeDiff) > 0 || len(afterDiff) > 0 {
t.Fatalf("before diff: (%s), after diff: (%s)", beforeDiff, afterDiff)
}
}
}

View File

@ -1,10 +1,38 @@
package differ
import (
"github.com/zclconf/go-cty/cty"
ctyjson "github.com/zclconf/go-cty/cty/json"
"github.com/hashicorp/terraform/internal/command/jsonformat/change"
"github.com/hashicorp/terraform/internal/command/jsonprovider"
)
func (v Value) ComputeChangeForAttribute(attribute *jsonprovider.Attribute) change.Change {
return v.ComputeChangeForType(unmarshalAttribute(attribute))
}
func (v Value) ComputeChangeForType(ctyType cty.Type) change.Change {
switch {
case ctyType.IsPrimitiveType():
return v.computeAttributeChangeAsPrimitive(ctyType)
default:
panic("not implemented")
}
}
func unmarshalAttribute(attribute *jsonprovider.Attribute) cty.Type {
if attribute.AttributeNestedType != nil {
children := make(map[string]cty.Type)
for key, child := range attribute.AttributeNestedType.Attributes {
children[key] = unmarshalAttribute(child)
}
return cty.Object(children)
}
ctyType, err := ctyjson.UnmarshalType(attribute.AttributeType)
if err != nil {
panic("could not unmarshal attribute type: " + err.Error())
}
return ctyType
}

View File

@ -0,0 +1,37 @@
package differ
import (
"fmt"
"github.com/zclconf/go-cty/cty"
"github.com/hashicorp/terraform/internal/command/jsonformat/change"
)
func strptr(str string) *string {
return &str
}
func (v Value) computeAttributeChangeAsPrimitive(ctyType cty.Type) change.Change {
return v.AsChange(change.Primitive(formatAsPrimitive(v.Before, ctyType), formatAsPrimitive(v.After, ctyType)))
}
func formatAsPrimitive(value interface{}, ctyType cty.Type) *string {
if value == nil {
return nil
}
switch {
case ctyType == cty.String:
return strptr(fmt.Sprintf("\"%s\"", value))
case ctyType == cty.Bool:
if value.(bool) {
return strptr("true")
}
return strptr("false")
case ctyType == cty.Number:
return strptr(fmt.Sprintf("%g", value))
default:
panic("unrecognized primitive type: " + ctyType.FriendlyName())
}
}

View File

@ -2,8 +2,15 @@ package differ
import (
"encoding/json"
"fmt"
"reflect"
"github.com/zclconf/go-cty/cty"
"github.com/hashicorp/terraform/internal/command/jsonformat/change"
"github.com/hashicorp/terraform/internal/command/jsonplan"
"github.com/hashicorp/terraform/internal/command/jsonprovider"
"github.com/hashicorp/terraform/internal/plans"
)
// Value contains the unmarshalled generic interface{} types that are output by
@ -26,19 +33,20 @@ import (
// the type would need to change between the before and after value. It is in
// fact just easier to iterate through the values as generic JSON interfaces.
type Value struct {
// BeforeExplicit refers to whether the Before value is explicit or
// BeforeExplicit matches AfterExplicit except references the Before value.
BeforeExplicit bool
// AfterExplicit refers to whether the After value is explicit or
// implicit. It is explicit if it has been specified by the user, and
// implicit if it has been set as a consequence of other changes.
//
// For example, explicitly setting a value to null in a list should result
// in Before being null and BeforeExplicit being true. In comparison,
// removing an element from a list should also result in Before being null
// and BeforeExplicit being false. Without the explicit information our
// in After being null and AfterExplicit being true. In comparison,
// removing an element from a list should also result in After being null
// and AfterExplicit being false. Without the explicit information our
// functions would not be able to tell the difference between these two
// cases.
BeforeExplicit bool
// AfterExplicit matches BeforeExplicit except references the After value.
AfterExplicit bool
// Before contains the value before the proposed change.
@ -90,6 +98,62 @@ func ValueFromJsonChange(change jsonplan.Change) Value {
}
}
// ComputeChange is a generic function that lets callers no worry about what
// type of change they are processing.
//
// It can accept blocks, attributes, go-cty types, and outputs, and will route
// the request to the appropriate function.
func (v Value) ComputeChange(changeType interface{}) change.Change {
switch concrete := changeType.(type) {
case *jsonprovider.Attribute:
return v.ComputeChangeForAttribute(concrete)
case cty.Type:
return v.ComputeChangeForType(concrete)
default:
panic(fmt.Sprintf("unrecognized change type: %T", changeType))
}
}
func (v Value) AsChange(renderer change.Renderer) change.Change {
return change.New(renderer, v.calculateChange(), v.replacePath())
}
func (v Value) isBeforeSensitive() bool {
if sensitive, ok := v.BeforeSensitive.(bool); ok {
return sensitive
}
return false
}
func (v Value) isAfterSensitive() bool {
if sensitive, ok := v.AfterSensitive.(bool); ok {
return sensitive
}
return false
}
func (v Value) replacePath() bool {
if replace, ok := v.ReplacePaths.(bool); ok {
return replace
}
return false
}
func (v Value) calculateChange() plans.Action {
if (v.Before == nil && !v.BeforeExplicit) && (v.After != nil || v.AfterExplicit) {
return plans.Create
}
if (v.After == nil && !v.AfterExplicit) && (v.Before != nil || v.BeforeExplicit) {
return plans.Delete
}
if reflect.DeepEqual(v.Before, v.After) && v.AfterExplicit == v.BeforeExplicit && v.isAfterSensitive() == v.isBeforeSensitive() {
return plans.NoOp
}
return plans.Update
}
func unmarshalGeneric(raw json.RawMessage) interface{} {
if raw == nil {
return nil

View File

@ -0,0 +1,90 @@
package differ
import (
"testing"
"github.com/hashicorp/terraform/internal/command/jsonformat/change"
"github.com/hashicorp/terraform/internal/command/jsonprovider"
"github.com/hashicorp/terraform/internal/plans"
)
func TestValue_Attribute(t *testing.T) {
tcs := map[string]struct {
input Value
attribute *jsonprovider.Attribute
expectedAction plans.Action
expectedReplace bool
validateChange change.ValidateChangeFunc
}{
"primitive_create": {
input: Value{
After: "new",
},
attribute: &jsonprovider.Attribute{
AttributeType: []byte("\"string\""),
},
expectedAction: plans.Create,
expectedReplace: false,
validateChange: change.ValidatePrimitive(nil, strptr("\"new\"")),
},
"primitive_delete": {
input: Value{
Before: "old",
},
attribute: &jsonprovider.Attribute{
AttributeType: []byte("\"string\""),
},
expectedAction: plans.Delete,
expectedReplace: false,
validateChange: change.ValidatePrimitive(strptr("\"old\""), nil),
},
"primitive_update": {
input: Value{
Before: "old",
After: "new",
},
attribute: &jsonprovider.Attribute{
AttributeType: []byte("\"string\""),
},
expectedAction: plans.Update,
expectedReplace: false,
validateChange: change.ValidatePrimitive(strptr("\"old\""), strptr("\"new\"")),
},
"primitive_set_explicit_null": {
input: Value{
Before: "old",
After: nil,
AfterExplicit: true,
},
attribute: &jsonprovider.Attribute{
AttributeType: []byte("\"string\""),
},
expectedAction: plans.Update,
expectedReplace: false,
validateChange: change.ValidatePrimitive(strptr("\"old\""), nil),
},
"primitive_unset_explicit_null": {
input: Value{
BeforeExplicit: true,
Before: nil,
After: "new",
},
attribute: &jsonprovider.Attribute{
AttributeType: []byte("\"string\""),
},
expectedAction: plans.Update,
expectedReplace: false,
validateChange: change.ValidatePrimitive(nil, strptr("\"new\"")),
},
}
for name, tc := range tcs {
t.Run(name, func(t *testing.T) {
change.ValidateChange(
t,
tc.validateChange,
tc.input.ComputeChangeForAttribute(tc.attribute),
tc.expectedAction,
tc.expectedReplace)
})
}
}