Add support for object attributes in the structured renderer (#32391)

* 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

* Add support for parsing and rendering sensitive values in the renderer

* Add support for unknown/computed values in the structured renderer

* delete missing unit tests

* Add support for object attributes in the structured renderer

* goimports
This commit is contained in:
Liam Cervante 2023-01-09 12:15:38 +01:00 committed by GitHub
parent b8b1a8d430
commit 1eebcf875f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 1018 additions and 65 deletions

View File

@ -1,6 +1,7 @@
package change
import (
"fmt"
"strings"
"github.com/hashicorp/terraform/internal/plans"
@ -60,6 +61,11 @@ func (change Change) Warnings(indent int) []string {
return change.renderer.Warnings(change, indent)
}
// GetAction returns the plans.Action that this change describes.
func (change Change) GetAction() plans.Action {
return change.action
}
// 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 {
@ -83,3 +89,19 @@ func (change Change) forcesReplacement() string {
func (change Change) indent(indent int) string {
return strings.Repeat(" ", indent)
}
// emptySymbol returns an empty string that is the same length as an action
// symbol (eg. ' +', '+/-', ...). It is used to offset additional lines in
// change renderer outputs alongside the indent function.
func (change Change) emptySymbol() string {
return " "
}
// unchanged prints out a description saying how many of 'keyword' have been
// hidden because they are unchanged or noop actions.
func (change Change) unchanged(keyword string, count int) string {
if count == 1 {
return fmt.Sprintf("[dark_gray]# (%d unchanged %s hidden)[reset]", count, keyword)
}
return fmt.Sprintf("[dark_gray]# (%d unchanged %ss hidden)[reset]", count, keyword)
}

View File

@ -0,0 +1,88 @@
package change
import (
"bytes"
"fmt"
"sort"
"github.com/hashicorp/terraform/internal/command/format"
"github.com/hashicorp/terraform/internal/plans"
)
func Object(attributes map[string]Change) Renderer {
maximumKeyLen := 0
for key := range attributes {
if maximumKeyLen < len(key) {
maximumKeyLen = len(key)
}
}
return &objectRenderer{
attributes: attributes,
maximumKeyLen: maximumKeyLen,
overrideNullSuffix: true,
}
}
func NestedObject(attributes map[string]Change) Renderer {
maximumKeyLen := 0
for key := range attributes {
if maximumKeyLen < len(key) {
maximumKeyLen = len(key)
}
}
return &objectRenderer{
attributes: attributes,
maximumKeyLen: maximumKeyLen,
overrideNullSuffix: false,
}
}
type objectRenderer struct {
NoWarningsRenderer
attributes map[string]Change
maximumKeyLen int
overrideNullSuffix bool
}
func (renderer objectRenderer) Render(change Change, indent int, opts RenderOpts) string {
if len(renderer.attributes) == 0 {
return fmt.Sprintf("{}%s%s", change.nullSuffix(opts.overrideNullSuffix), change.forcesReplacement())
}
attributeOpts := opts.Clone()
attributeOpts.overrideNullSuffix = renderer.overrideNullSuffix
var keys []string
for key := range renderer.attributes {
keys = append(keys, key)
}
sort.Strings(keys)
unchangedAttributes := 0
var buf bytes.Buffer
buf.WriteString(fmt.Sprintf("{%s\n", change.forcesReplacement()))
for _, key := range keys {
attribute := renderer.attributes[key]
if attribute.action == plans.NoOp && !opts.showUnchangedChildren {
// Don't render NoOp operations when we are compact display.
unchangedAttributes++
continue
}
for _, warning := range attribute.Warnings(indent + 1) {
buf.WriteString(fmt.Sprintf("%s%s\n", change.indent(indent+1), warning))
}
buf.WriteString(fmt.Sprintf("%s%s %-*s = %s\n", change.indent(indent+1), format.DiffActionSymbol(attribute.action), renderer.maximumKeyLen, key, attribute.Render(indent+1, attributeOpts)))
}
if unchangedAttributes > 0 {
buf.WriteString(fmt.Sprintf("%s%s %s\n", change.indent(indent+1), change.emptySymbol(), change.unchanged("attribute", unchangedAttributes)))
}
buf.WriteString(fmt.Sprintf("%s%s }%s", change.indent(indent), change.emptySymbol(), change.nullSuffix(opts.overrideNullSuffix)))
return buf.String()
}

View File

@ -32,6 +32,14 @@ type RenderOpts struct {
// change, as such we provide this as an option instead of trying to
// calculate it inside a specific renderer.
overrideNullSuffix bool
// showUnchangedChildren instructs the Renderer to render all children of a
// given complex change, instead of hiding unchanged items and compressing
// them into a single line.
//
// This is generally decided by the parent change (mainly lists) and so is
// passed in as a private option.
showUnchangedChildren bool
}
// Clone returns a new RenderOpts object, that matches the original but can be

View File

@ -1,6 +1,7 @@
package change
import (
"strings"
"testing"
"github.com/google/go-cmp/cmp"
@ -107,12 +108,256 @@ func TestRenderers(t *testing.T) {
},
expected: "0 -> (known after apply)",
},
"object_created": {
change: Change{
renderer: Object(map[string]Change{}),
action: plans.Create,
},
expected: "{}",
},
"object_created_with_attributes": {
change: Change{
renderer: Object(map[string]Change{
"attribute_one": {
renderer: Primitive(nil, strptr("0")),
action: plans.Create,
},
}),
action: plans.Create,
},
expected: `
{
+ attribute_one = 0
}
`,
},
"object_deleted": {
change: Change{
renderer: Object(map[string]Change{}),
action: plans.Delete,
},
expected: "{} -> null",
},
"object_deleted_with_attributes": {
change: Change{
renderer: Object(map[string]Change{
"attribute_one": {
renderer: Primitive(strptr("0"), nil),
action: plans.Delete,
},
}),
action: plans.Delete,
},
expected: `
{
- attribute_one = 0
} -> null
`,
},
"nested_object_deleted": {
change: Change{
renderer: NestedObject(map[string]Change{}),
action: plans.Delete,
},
expected: "{} -> null",
},
"nested_object_deleted_with_attributes": {
change: Change{
renderer: NestedObject(map[string]Change{
"attribute_one": {
renderer: Primitive(strptr("0"), nil),
action: plans.Delete,
},
}),
action: plans.Delete,
},
expected: `
{
- attribute_one = 0 -> null
} -> null
`,
},
"object_create_attribute": {
change: Change{
renderer: Object(map[string]Change{
"attribute_one": {
renderer: Primitive(nil, strptr("0")),
action: plans.Create,
},
}),
action: plans.Update,
},
expected: `
{
+ attribute_one = 0
}
`,
},
"object_update_attribute": {
change: Change{
renderer: Object(map[string]Change{
"attribute_one": {
renderer: Primitive(strptr("0"), strptr("1")),
action: plans.Update,
},
}),
action: plans.Update,
},
expected: `
{
~ attribute_one = 0 -> 1
}
`,
},
"object_update_attribute_forces_replacement": {
change: Change{
renderer: Object(map[string]Change{
"attribute_one": {
renderer: Primitive(strptr("0"), strptr("1")),
action: plans.Update,
},
}),
action: plans.Update,
replace: true,
},
expected: `
{ # forces replacement
~ attribute_one = 0 -> 1
}
`,
},
"object_delete_attribute": {
change: Change{
renderer: Object(map[string]Change{
"attribute_one": {
renderer: Primitive(strptr("0"), nil),
action: plans.Delete,
},
}),
action: plans.Update,
},
expected: `
{
- attribute_one = 0
}
`,
},
"object_ignore_unchanged_attributes": {
change: Change{
renderer: Object(map[string]Change{
"attribute_one": {
renderer: Primitive(strptr("0"), strptr("1")),
action: plans.Update,
},
"attribute_two": {
renderer: Primitive(strptr("0"), strptr("0")),
action: plans.NoOp,
},
"attribute_three": {
renderer: Primitive(nil, strptr("1")),
action: plans.Create,
},
}),
action: plans.Update,
},
expected: `
{
~ attribute_one = 0 -> 1
+ attribute_three = 1
# (1 unchanged attribute hidden)
}
`,
},
"object_create_sensitive_attribute": {
change: Change{
renderer: Object(map[string]Change{
"attribute_one": {
renderer: Sensitive(nil, 1, false, true),
action: plans.Create,
},
}),
action: plans.Update,
},
expected: `
{
+ attribute_one = (sensitive)
}
`,
},
"object_update_sensitive_attribute": {
change: Change{
renderer: Object(map[string]Change{
"attribute_one": {
renderer: Sensitive(nil, 1, false, true),
action: plans.Update,
},
}),
action: plans.Update,
},
expected: `
{
~ attribute_one = (sensitive)
}
`,
},
"object_delete_sensitive_attribute": {
change: Change{
renderer: Object(map[string]Change{
"attribute_one": {
renderer: Sensitive(nil, 1, false, true),
action: plans.Delete,
},
}),
action: plans.Update,
},
expected: `
{
- attribute_one = (sensitive)
}
`,
},
"object_create_computed_attribute": {
change: Change{
renderer: Object(map[string]Change{
"attribute_one": {
renderer: Computed(Change{renderer: nil}),
action: plans.Create,
},
}),
action: plans.Update,
},
expected: `
{
+ attribute_one = (known after apply)
}
`,
},
"object_update_computed_attribute": {
change: Change{
renderer: Object(map[string]Change{
"attribute_one": {
renderer: Computed(Change{
renderer: Primitive(strptr("1"), nil),
action: plans.Delete,
}),
action: plans.Update,
},
}),
action: plans.Update,
},
expected: `
{
~ attribute_one = 1 -> (known after apply)
}
`,
},
}
for name, tc := range tcs {
t.Run(name, func(t *testing.T) {
expected := strings.TrimSpace(tc.expected)
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)
if diff := cmp.Diff(expected, actual); len(diff) > 0 {
t.Fatalf("\nexpected:\n%s\nactual:\n%s\ndiff:\n%s\n", expected, actual, diff)
}
})
}

View File

@ -1,6 +1,7 @@
package change
import (
"strings"
"testing"
"github.com/google/go-cmp/cmp"
@ -10,16 +11,16 @@ import (
type ValidateChangeFunc func(t *testing.T, change Change)
func ValidateChange(t *testing.T, f ValidateChangeFunc, change Change, expectedAction plans.Action, expectedReplace bool) {
func validateChange(t *testing.T, 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 {
func ValidatePrimitive(before, after *string, action plans.Action, replace bool) ValidateChangeFunc {
return func(t *testing.T, change Change) {
validateChange(t, change, action, replace)
primitive, ok := change.renderer.(*primitiveRenderer)
if !ok {
t.Fatalf("invalid renderer type: %T", change.renderer)
@ -34,8 +35,68 @@ func ValidatePrimitive(before, after *string) ValidateChangeFunc {
}
}
func ValidateSensitive(before, after interface{}, beforeSensitive, afterSensitive bool) ValidateChangeFunc {
func ValidateObject(attributes map[string]ValidateChangeFunc, action plans.Action, replace bool) ValidateChangeFunc {
return func(t *testing.T, change Change) {
validateChange(t, change, action, replace)
object, ok := change.renderer.(*objectRenderer)
if !ok {
t.Fatalf("invalid renderer type: %T", change.renderer)
}
if !object.overrideNullSuffix {
t.Fatalf("created the wrong type of object renderer")
}
validateObject(t, object, attributes)
}
}
func ValidateNestedObject(attributes map[string]ValidateChangeFunc, action plans.Action, replace bool) ValidateChangeFunc {
return func(t *testing.T, change Change) {
validateChange(t, change, action, replace)
object, ok := change.renderer.(*objectRenderer)
if !ok {
t.Fatalf("invalid renderer type: %T", change.renderer)
}
if object.overrideNullSuffix {
t.Fatalf("created the wrong type of object renderer")
}
validateObject(t, object, attributes)
}
}
func validateObject(t *testing.T, object *objectRenderer, attributes map[string]ValidateChangeFunc) {
if len(object.attributes) != len(attributes) {
t.Fatalf("expected %d attributes but found %d attributes", len(attributes), len(object.attributes))
}
var missing []string
for key, expected := range attributes {
actual, ok := object.attributes[key]
if !ok {
missing = append(missing, key)
}
if len(missing) > 0 {
continue
}
expected(t, actual)
}
if len(missing) > 0 {
t.Fatalf("missing the following attributes: %s", strings.Join(missing, ", "))
}
}
func ValidateSensitive(before, after interface{}, beforeSensitive, afterSensitive bool, action plans.Action, replace bool) ValidateChangeFunc {
return func(t *testing.T, change Change) {
validateChange(t, change, action, replace)
sensitive, ok := change.renderer.(*sensitiveRenderer)
if !ok {
t.Fatalf("invalid renderer type: %T", change.renderer)
@ -54,8 +115,10 @@ func ValidateSensitive(before, after interface{}, beforeSensitive, afterSensitiv
}
}
func ValidateComputed(before ValidateChangeFunc) ValidateChangeFunc {
func ValidateComputed(before ValidateChangeFunc, action plans.Action, replace bool) ValidateChangeFunc {
return func(t *testing.T, change Change) {
validateChange(t, change, action, replace)
computed, ok := change.renderer.(*computedRenderer)
if !ok {
t.Fatalf("invalid renderer type: %T", change.renderer)

View File

@ -8,37 +8,34 @@ import (
"github.com/hashicorp/terraform/internal/command/jsonprovider"
)
func (v Value) ComputeChangeForAttribute(attribute *jsonprovider.Attribute) change.Change {
return v.ComputeChangeForType(unmarshalAttribute(attribute))
func (v Value) computeChangeForAttribute(attribute *jsonprovider.Attribute) change.Change {
if attribute.AttributeNestedType != nil {
return v.computeChangeForNestedAttribute(attribute.AttributeNestedType)
}
return v.computeChangeForType(unmarshalAttribute(attribute))
}
func (v Value) ComputeChangeForType(ctyType cty.Type) change.Change {
if sensitive, ok := v.checkForSensitive(); ok {
return sensitive
}
if computed, ok := v.checkForComputed(ctyType); ok {
return computed
func (v Value) computeChangeForNestedAttribute(attribute *jsonprovider.NestedType) change.Change {
switch attribute.NestingMode {
case "single", "group":
return v.computeAttributeChangeAsNestedObject(attribute.Attributes)
default:
panic("unrecognized nesting mode: " + attribute.NestingMode)
}
}
func (v Value) computeChangeForType(ctyType cty.Type) change.Change {
switch {
case ctyType.IsPrimitiveType():
return v.computeAttributeChangeAsPrimitive(ctyType)
case ctyType.IsObjectType():
return v.computeAttributeChangeAsObject(ctyType.AttributeTypes())
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())

View File

@ -1,12 +1,10 @@
package differ
import (
"github.com/zclconf/go-cty/cty"
"github.com/hashicorp/terraform/internal/command/jsonformat/change"
)
func (v Value) checkForComputed(ctyType cty.Type) (change.Change, bool) {
func (v Value) checkForComputed(changeType interface{}) (change.Change, bool) {
unknown := v.isUnknown()
if !unknown {
@ -30,7 +28,7 @@ func (v Value) checkForComputed(ctyType cty.Type) (change.Change, bool) {
Before: v.Before,
BeforeSensitive: v.BeforeSensitive,
}
return v.AsChange(change.Computed(beforeValue.ComputeChangeForType(ctyType))), true
return v.AsChange(change.Computed(beforeValue.ComputeChange(changeType))), true
}
func (v Value) isUnknown() bool {

View File

@ -0,0 +1,61 @@
package differ
import (
"github.com/zclconf/go-cty/cty"
"github.com/hashicorp/terraform/internal/command/jsonformat/change"
"github.com/hashicorp/terraform/internal/command/jsonprovider"
"github.com/hashicorp/terraform/internal/plans"
)
func (v Value) computeAttributeChangeAsObject(attributes map[string]cty.Type) change.Change {
var keys []string
for key := range attributes {
keys = append(keys, key)
}
attributeChanges, changeType := v.processObject(keys, func(key string) interface{} {
return attributes[key]
})
return change.New(change.Object(attributeChanges), changeType, v.replacePath())
}
func (v Value) computeAttributeChangeAsNestedObject(attributes map[string]*jsonprovider.Attribute) change.Change {
var keys []string
for key := range attributes {
keys = append(keys, key)
}
attributeChanges, changeType := v.processObject(keys, func(key string) interface{} {
return attributes[key]
})
return change.New(change.NestedObject(attributeChanges), changeType, v.replacePath())
}
func (v Value) processObject(keys []string, getAttribute func(string) interface{}) (map[string]change.Change, plans.Action) {
attributeChanges := make(map[string]change.Change)
mapValue := v.asMap()
currentAction := v.getDefaultActionForIteration()
for _, key := range keys {
attribute := getAttribute(key)
attributeValue := mapValue.getChild(key)
// We always assume changes to object are implicit.
attributeValue.BeforeExplicit = false
attributeValue.AfterExplicit = false
// We use the generic ComputeChange here, as we don't know whether this
// is from a nested object or a `normal` object.
attributeChange := attributeValue.ComputeChange(attribute)
if attributeChange.GetAction() == plans.NoOp && attributeValue.Before == nil && attributeValue.After == nil {
// We skip attributes of objects that are null both before and
// after. We don't even count these as unchanged attributes.
continue
}
attributeChanges[key] = attributeChange
currentAction = compareActions(currentAction, attributeChange.GetAction())
}
return attributeChanges, currentAction
}

View File

@ -18,7 +18,7 @@ import (
// jsonprovider).
//
// A Value can be converted into a change.Change, ready for rendering, with the
// ComputeChangeForAttribute, ComputeChangeForOutput, and ComputeChangeForBlock
// computeChangeForAttribute, ComputeChangeForOutput, and ComputeChangeForBlock
// functions.
//
// The Before and After fields are actually go-cty values, but we cannot convert
@ -99,16 +99,26 @@ func ValueFromJsonChange(change jsonplan.Change) Value {
}
// ComputeChange is a generic function that lets callers no worry about what
// type of change they are processing.
// type of change they are processing. In general, this is the function external
// users should call as it has some generic preprocessing applicable to all
// types.
//
// 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 {
if sensitive, ok := v.checkForSensitive(); ok {
return sensitive
}
if computed, ok := v.checkForComputed(changeType); ok {
return computed
}
switch concrete := changeType.(type) {
case *jsonprovider.Attribute:
return v.ComputeChangeForAttribute(concrete)
return v.computeChangeForAttribute(concrete)
case cty.Type:
return v.ComputeChangeForType(concrete)
return v.computeChangeForType(concrete)
default:
panic(fmt.Sprintf("unrecognized change type: %T", changeType))
}
@ -140,6 +150,43 @@ func (v Value) calculateChange() plans.Action {
return plans.Update
}
// getDefaultActionForIteration is used to guess what the change could be for
// complex attributes (collections and objects) and blocks.
//
// You can't really tell the difference between a NoOp and an Update just by
// looking at the attribute itself as you need to inspect the children.
//
// This function returns a Delete or a Create action if the before or after
// values were null, and returns a NoOp for all other cases. It should be used
// in conjunction with compareActions to calculate the actual action based on
// the actions of the children.
func (v Value) getDefaultActionForIteration() plans.Action {
if v.Before == nil && v.After == nil {
return plans.NoOp
}
if v.Before == nil {
return plans.Create
}
if v.After == nil {
return plans.Delete
}
return plans.NoOp
}
// compareActions will compare current and next, and return plans.Update if they
// are different, and current if they are the same.
//
// This function should be used in conjunction with getDefaultActionForIteration
// to convert a NoOp default action into an Update based on the actions of a
// values children.
func compareActions(current, next plans.Action) plans.Action {
if current != next {
return plans.Update
}
return current
}
func unmarshalGeneric(raw json.RawMessage) interface{} {
if raw == nil {
return nil

View File

@ -0,0 +1,69 @@
package differ
// ValueMap is a Value that represents a Map or an Object type, and has
// converted the relevant interfaces into maps for easier access.
type ValueMap struct {
// Before contains the value before the proposed change.
Before map[string]interface{}
// After contains the value after the proposed change.
After map[string]interface{}
// Unknown contains the unknown status of any elements/attributes of this
// map/object.
Unknown map[string]interface{}
// BeforeSensitive contains the before sensitive status of any
// elements/attributes of this map/object.
BeforeSensitive map[string]interface{}
// AfterSensitive contains the after sensitive status of any
// elements/attributes of this map/object.
AfterSensitive map[string]interface{}
}
func (v Value) asMap() ValueMap {
return ValueMap{
Before: genericToMap(v.Before),
After: genericToMap(v.After),
Unknown: genericToMap(v.Unknown),
BeforeSensitive: genericToMap(v.BeforeSensitive),
AfterSensitive: genericToMap(v.AfterSensitive),
}
}
func (m ValueMap) getChild(key string) Value {
before, beforeExplicit := getFromGenericMap(m.Before, key)
after, afterExplicit := getFromGenericMap(m.After, key)
unknown, _ := getFromGenericMap(m.Unknown, key)
beforeSensitive, _ := getFromGenericMap(m.BeforeSensitive, key)
afterSensitive, _ := getFromGenericMap(m.AfterSensitive, key)
return Value{
BeforeExplicit: beforeExplicit,
AfterExplicit: afterExplicit,
Before: before,
After: after,
Unknown: unknown,
BeforeSensitive: beforeSensitive,
AfterSensitive: afterSensitive,
}
}
func getFromGenericMap(generic map[string]interface{}, key string) (interface{}, bool) {
if generic == nil {
return nil, false
}
if child, ok := generic[key]; ok {
return child, ok
}
return nil, false
}
func genericToMap(generic interface{}) map[string]interface{} {
if concrete, ok := generic.(map[string]interface{}); ok {
return concrete
}
return nil
}

View File

@ -1,20 +1,382 @@
package differ
import (
"encoding/json"
"fmt"
"testing"
"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"
"github.com/hashicorp/terraform/internal/plans"
)
func TestValue_ObjectAttributes(t *testing.T) {
// We break these tests out into their own function, so we can automatically
// test both objects and nested objects together.
tcs := map[string]struct {
input Value
attributes map[string]cty.Type
validateSingleChange change.ValidateChangeFunc
validateObject change.ValidateChangeFunc
validateNestedObject change.ValidateChangeFunc
validateChanges map[string]change.ValidateChangeFunc
validateReplace bool
validateAction plans.Action
}{
"object_create": {
input: Value{
Before: nil,
After: map[string]interface{}{
"attribute_one": "new",
},
},
attributes: map[string]cty.Type{
"attribute_one": cty.String,
},
validateChanges: map[string]change.ValidateChangeFunc{
"attribute_one": change.ValidatePrimitive(nil, strptr("\"new\""), plans.Create, false),
},
validateAction: plans.Create,
validateReplace: false,
},
"object_delete": {
input: Value{
Before: map[string]interface{}{
"attribute_one": "old",
},
After: nil,
},
attributes: map[string]cty.Type{
"attribute_one": cty.String,
},
validateChanges: map[string]change.ValidateChangeFunc{
"attribute_one": change.ValidatePrimitive(strptr("\"old\""), nil, plans.Delete, false),
},
validateAction: plans.Delete,
validateReplace: false,
},
"object_create_sensitive": {
input: Value{
Before: nil,
After: map[string]interface{}{
"attribute_one": "new",
},
AfterSensitive: true,
},
attributes: map[string]cty.Type{
"attribute_one": cty.String,
},
validateSingleChange: change.ValidateSensitive(nil, map[string]interface{}{
"attribute_one": "new",
}, false, true, plans.Create, false),
},
"object_delete_sensitive": {
input: Value{
Before: map[string]interface{}{
"attribute_one": "old",
},
BeforeSensitive: true,
After: nil,
},
attributes: map[string]cty.Type{
"attribute_one": cty.String,
},
validateSingleChange: change.ValidateSensitive(map[string]interface{}{
"attribute_one": "old",
}, nil, true, false, plans.Delete, false),
},
"object_create_unknown": {
input: Value{
Before: nil,
After: nil,
Unknown: true,
},
attributes: map[string]cty.Type{
"attribute_one": cty.String,
},
validateSingleChange: change.ValidateComputed(nil, plans.Create, false),
},
"object_update_unknown": {
input: Value{
Before: map[string]interface{}{
"attribute_one": "old",
},
After: nil,
Unknown: true,
},
attributes: map[string]cty.Type{
"attribute_one": cty.String,
},
validateObject: change.ValidateComputed(change.ValidateObject(map[string]change.ValidateChangeFunc{
"attribute_one": change.ValidatePrimitive(strptr("\"old\""), nil, plans.Delete, false),
}, plans.Delete, false), plans.Update, false),
validateNestedObject: change.ValidateComputed(change.ValidateNestedObject(map[string]change.ValidateChangeFunc{
"attribute_one": change.ValidatePrimitive(strptr("\"old\""), nil, plans.Delete, false),
}, plans.Delete, false), plans.Update, false),
},
"object_create_attribute": {
input: Value{
Before: map[string]interface{}{},
After: map[string]interface{}{
"attribute_one": "new",
},
},
attributes: map[string]cty.Type{
"attribute_one": cty.String,
},
validateChanges: map[string]change.ValidateChangeFunc{
"attribute_one": change.ValidatePrimitive(nil, strptr("\"new\""), plans.Create, false),
},
validateAction: plans.Update,
validateReplace: false,
},
"object_create_attribute_from_explicit_null": {
input: Value{
Before: map[string]interface{}{
"attribute_one": nil,
},
After: map[string]interface{}{
"attribute_one": "new",
},
},
attributes: map[string]cty.Type{
"attribute_one": cty.String,
},
validateChanges: map[string]change.ValidateChangeFunc{
"attribute_one": change.ValidatePrimitive(nil, strptr("\"new\""), plans.Create, false),
},
validateAction: plans.Update,
validateReplace: false,
},
"object_delete_attribute": {
input: Value{
Before: map[string]interface{}{
"attribute_one": "old",
},
After: map[string]interface{}{},
},
attributes: map[string]cty.Type{
"attribute_one": cty.String,
},
validateChanges: map[string]change.ValidateChangeFunc{
"attribute_one": change.ValidatePrimitive(strptr("\"old\""), nil, plans.Delete, false),
},
validateAction: plans.Update,
validateReplace: false,
},
"object_delete_attribute_to_explicit_null": {
input: Value{
Before: map[string]interface{}{
"attribute_one": "old",
},
After: map[string]interface{}{
"attribute_one": nil,
},
},
attributes: map[string]cty.Type{
"attribute_one": cty.String,
},
validateChanges: map[string]change.ValidateChangeFunc{
"attribute_one": change.ValidatePrimitive(strptr("\"old\""), nil, plans.Delete, false),
},
validateAction: plans.Update,
validateReplace: false,
},
"object_update_attribute": {
input: Value{
Before: map[string]interface{}{
"attribute_one": "old",
},
After: map[string]interface{}{
"attribute_one": "new",
},
},
attributes: map[string]cty.Type{
"attribute_one": cty.String,
},
validateChanges: map[string]change.ValidateChangeFunc{
"attribute_one": change.ValidatePrimitive(strptr("\"old\""), strptr("\"new\""), plans.Update, false),
},
validateAction: plans.Update,
validateReplace: false,
},
"object_create_sensitive_attribute": {
input: Value{
Before: map[string]interface{}{},
After: map[string]interface{}{
"attribute_one": "new",
},
AfterSensitive: map[string]interface{}{
"attribute_one": true,
},
},
attributes: map[string]cty.Type{
"attribute_one": cty.String,
},
validateChanges: map[string]change.ValidateChangeFunc{
"attribute_one": change.ValidateSensitive(nil, "new", false, true, plans.Create, false),
},
validateAction: plans.Update,
validateReplace: false,
},
"object_delete_sensitive_attribute": {
input: Value{
Before: map[string]interface{}{
"attribute_one": "old",
},
BeforeSensitive: map[string]interface{}{
"attribute_one": true,
},
After: map[string]interface{}{},
},
attributes: map[string]cty.Type{
"attribute_one": cty.String,
},
validateChanges: map[string]change.ValidateChangeFunc{
"attribute_one": change.ValidateSensitive("old", nil, true, false, plans.Delete, false),
},
validateAction: plans.Update,
validateReplace: false,
},
"object_update_sensitive_attribute": {
input: Value{
Before: map[string]interface{}{
"attribute_one": "old",
},
BeforeSensitive: map[string]interface{}{
"attribute_one": true,
},
After: map[string]interface{}{
"attribute_one": "new",
},
AfterSensitive: map[string]interface{}{
"attribute_one": true,
},
},
attributes: map[string]cty.Type{
"attribute_one": cty.String,
},
validateChanges: map[string]change.ValidateChangeFunc{
"attribute_one": change.ValidateSensitive("old", "new", true, true, plans.Update, false),
},
validateAction: plans.Update,
validateReplace: false,
},
"object_create_computed_attribute": {
input: Value{
Before: map[string]interface{}{},
After: map[string]interface{}{},
Unknown: map[string]interface{}{
"attribute_one": true,
},
},
attributes: map[string]cty.Type{
"attribute_one": cty.String,
},
validateChanges: map[string]change.ValidateChangeFunc{
"attribute_one": change.ValidateComputed(nil, plans.Create, false),
},
validateAction: plans.Update,
validateReplace: false,
},
"object_update_computed_attribute": {
input: Value{
Before: map[string]interface{}{
"attribute_one": "old",
},
After: map[string]interface{}{},
Unknown: map[string]interface{}{
"attribute_one": true,
},
},
attributes: map[string]cty.Type{
"attribute_one": cty.String,
},
validateChanges: map[string]change.ValidateChangeFunc{
"attribute_one": change.ValidateComputed(
change.ValidatePrimitive(strptr("\"old\""), nil, plans.Delete, false),
plans.Update,
false),
},
validateAction: plans.Update,
validateReplace: false,
},
"object_ignores_unset_fields": {
input: Value{
Before: map[string]interface{}{},
After: map[string]interface{}{},
},
attributes: map[string]cty.Type{
"attribute_one": cty.String,
},
validateChanges: map[string]change.ValidateChangeFunc{},
validateAction: plans.NoOp,
validateReplace: false,
},
}
for name, tc := range tcs {
t.Run(name, func(t *testing.T) {
attribute := &jsonprovider.Attribute{
AttributeType: unmarshalType(t, cty.Object(tc.attributes)),
}
if tc.validateObject != nil {
tc.validateObject(t, tc.input.ComputeChange(attribute))
return
}
if tc.validateSingleChange != nil {
tc.validateSingleChange(t, tc.input.ComputeChange(attribute))
return
}
validate := change.ValidateObject(tc.validateChanges, tc.validateAction, tc.validateReplace)
validate(t, tc.input.ComputeChange(attribute))
})
t.Run(fmt.Sprintf("nested_%s", name), func(t *testing.T) {
attribute := &jsonprovider.Attribute{
AttributeNestedType: &jsonprovider.NestedType{
Attributes: func() map[string]*jsonprovider.Attribute {
attributes := make(map[string]*jsonprovider.Attribute)
for key, attribute := range tc.attributes {
attributes[key] = &jsonprovider.Attribute{
AttributeType: unmarshalType(t, attribute),
}
}
return attributes
}(),
NestingMode: "single",
},
}
if tc.validateNestedObject != nil {
tc.validateNestedObject(t, tc.input.ComputeChange(attribute))
return
}
if tc.validateSingleChange != nil {
tc.validateSingleChange(t, tc.input.ComputeChange(attribute))
return
}
validate := change.ValidateNestedObject(tc.validateChanges, tc.validateAction, tc.validateReplace)
validate(t, tc.input.ComputeChange(attribute))
})
}
}
func TestValue_Attribute(t *testing.T) {
tcs := map[string]struct {
input Value
attribute *jsonprovider.Attribute
expectedAction plans.Action
expectedReplace bool
validateChange change.ValidateChangeFunc
input Value
attribute *jsonprovider.Attribute
validateChange change.ValidateChangeFunc
}{
"primitive_create": {
input: Value{
@ -23,8 +385,7 @@ func TestValue_Attribute(t *testing.T) {
attribute: &jsonprovider.Attribute{
AttributeType: []byte("\"string\""),
},
expectedAction: plans.Create,
validateChange: change.ValidatePrimitive(nil, strptr("\"new\"")),
validateChange: change.ValidatePrimitive(nil, strptr("\"new\""), plans.Create, false),
},
"primitive_delete": {
input: Value{
@ -33,8 +394,7 @@ func TestValue_Attribute(t *testing.T) {
attribute: &jsonprovider.Attribute{
AttributeType: []byte("\"string\""),
},
expectedAction: plans.Delete,
validateChange: change.ValidatePrimitive(strptr("\"old\""), nil),
validateChange: change.ValidatePrimitive(strptr("\"old\""), nil, plans.Delete, false),
},
"primitive_update": {
input: Value{
@ -44,8 +404,7 @@ func TestValue_Attribute(t *testing.T) {
attribute: &jsonprovider.Attribute{
AttributeType: []byte("\"string\""),
},
expectedAction: plans.Update,
validateChange: change.ValidatePrimitive(strptr("\"old\""), strptr("\"new\"")),
validateChange: change.ValidatePrimitive(strptr("\"old\""), strptr("\"new\""), plans.Update, false),
},
"primitive_set_explicit_null": {
input: Value{
@ -56,8 +415,7 @@ func TestValue_Attribute(t *testing.T) {
attribute: &jsonprovider.Attribute{
AttributeType: []byte("\"string\""),
},
expectedAction: plans.Update,
validateChange: change.ValidatePrimitive(strptr("\"old\""), nil),
validateChange: change.ValidatePrimitive(strptr("\"old\""), nil, plans.Update, false),
},
"primitive_unset_explicit_null": {
input: Value{
@ -68,8 +426,7 @@ func TestValue_Attribute(t *testing.T) {
attribute: &jsonprovider.Attribute{
AttributeType: []byte("\"string\""),
},
expectedAction: plans.Update,
validateChange: change.ValidatePrimitive(nil, strptr("\"new\"")),
validateChange: change.ValidatePrimitive(nil, strptr("\"new\""), plans.Update, false),
},
"primitive_create_sensitive": {
input: Value{
@ -80,8 +437,7 @@ func TestValue_Attribute(t *testing.T) {
attribute: &jsonprovider.Attribute{
AttributeType: []byte("\"string\""),
},
expectedAction: plans.Create,
validateChange: change.ValidateSensitive(nil, "new", false, true),
validateChange: change.ValidateSensitive(nil, "new", false, true, plans.Create, false),
},
"primitive_delete_sensitive": {
input: Value{
@ -92,8 +448,7 @@ func TestValue_Attribute(t *testing.T) {
attribute: &jsonprovider.Attribute{
AttributeType: []byte("\"string\""),
},
expectedAction: plans.Delete,
validateChange: change.ValidateSensitive("old", nil, true, false),
validateChange: change.ValidateSensitive("old", nil, true, false, plans.Delete, false),
},
"primitive_update_sensitive": {
input: Value{
@ -105,8 +460,7 @@ func TestValue_Attribute(t *testing.T) {
attribute: &jsonprovider.Attribute{
AttributeType: []byte("\"string\""),
},
expectedAction: plans.Update,
validateChange: change.ValidateSensitive("old", "new", true, true),
validateChange: change.ValidateSensitive("old", "new", true, true, plans.Update, false),
},
"primitive_create_computed": {
input: Value{
@ -117,8 +471,7 @@ func TestValue_Attribute(t *testing.T) {
attribute: &jsonprovider.Attribute{
AttributeType: []byte("\"string\""),
},
expectedAction: plans.Create,
validateChange: change.ValidateComputed(nil),
validateChange: change.ValidateComputed(nil, plans.Create, false),
},
"primitive_update_computed": {
input: Value{
@ -129,18 +482,20 @@ func TestValue_Attribute(t *testing.T) {
attribute: &jsonprovider.Attribute{
AttributeType: []byte("\"string\""),
},
expectedAction: plans.Update,
validateChange: change.ValidateComputed(change.ValidatePrimitive(strptr("\"old\""), nil)),
validateChange: change.ValidateComputed(change.ValidatePrimitive(strptr("\"old\""), nil, plans.Delete, false), plans.Update, false),
},
}
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)
tc.validateChange(t, tc.input.ComputeChange(tc.attribute))
})
}
}
func unmarshalType(t *testing.T, ctyType cty.Type) json.RawMessage {
msg, err := ctyjson.MarshalType(ctyType)
if err != nil {
t.Fatalf("invalid type: %s", ctyType.FriendlyName())
}
return msg
}