command/jsonplan: Add replace_paths

The set of paths which caused a resource update to require replacement
has been stored in the plan since 0.15.0 (#28201). This commit adds a
simple JSON representation of these paths, allowing consumers of this
format to determine exactly which paths caused the resource to be
replaced.

This representation is intentionally more loosely encoded than the JSON
state serialization of paths used for sensitive attributes. Instead of a
path step being represented by an object with type and value, we use a
more-JavaScripty heterogenous array of numbers and strings. Any
practical consumer of this format will likely traverse an object tree
using the index operator, which should work more easily with this
format. It also allows easy prefix comparison for consumers which are
tracking paths.

While updating the documentation to include this new field, I noticed
that some others were missing, so added them too.
This commit is contained in:
Alisdair McDiarmid 2021-05-04 16:51:51 -04:00
parent 53fab10b26
commit a5b7394f9a
4 changed files with 139 additions and 3 deletions

View File

@ -84,6 +84,14 @@ type change struct {
// display of sensitive values in user interfaces.
BeforeSensitive json.RawMessage `json:"before_sensitive,omitempty"`
AfterSensitive json.RawMessage `json:"after_sensitive,omitempty"`
// ReplacePaths is an array of arrays representing a set of paths into the
// object value which resulted in the action being "replace". This will be
// omitted if the action is not replace, or if no paths caused the
// replacement (for example, if the resource was tainted). Each path
// consists of one or more steps, each of which will be a number or a
// string.
ReplacePaths json.RawMessage `json:"replace_paths,omitempty"`
}
type output struct {
@ -257,6 +265,10 @@ func (p *plan) marshalResourceChanges(changes *plans.Changes, schemas *terraform
if err != nil {
return err
}
replacePaths, err := encodePaths(rc.RequiredReplace)
if err != nil {
return err
}
r.Change = change{
Actions: actionString(rc.Action.String()),
@ -265,6 +277,7 @@ func (p *plan) marshalResourceChanges(changes *plans.Changes, schemas *terraform
AfterUnknown: a,
BeforeSensitive: json.RawMessage(beforeSensitive),
AfterSensitive: json.RawMessage(afterSensitive),
ReplacePaths: replacePaths,
}
if rc.DeposedKey != states.NotDeposed {
@ -625,3 +638,54 @@ func actionString(action string) []string {
return []string{action}
}
}
// encodePaths lossily encodes a cty.PathSet into an array of arrays of step
// values, such as:
//
// [["length"],["triggers",0,"value"]]
//
// The lossiness is that we cannot distinguish between an IndexStep with string
// key and a GetAttr step. This is fine with JSON output, because JSON's type
// system means that those two steps are equivalent anyway: both are object
// indexes.
//
// JavaScript (or similar dynamic language) consumers of these values can
// recursively apply the steps to a given object using an index operation for
// each step.
func encodePaths(pathSet cty.PathSet) (json.RawMessage, error) {
if pathSet.Empty() {
return nil, nil
}
pathList := pathSet.List()
jsonPaths := make([]json.RawMessage, 0, len(pathList))
for _, path := range pathList {
steps := make([]json.RawMessage, 0, len(path))
for _, step := range path {
switch s := step.(type) {
case cty.IndexStep:
key, err := ctyjson.Marshal(s.Key, s.Key.Type())
if err != nil {
return nil, fmt.Errorf("Failed to marshal index step key %#v: %s", s.Key, err)
}
steps = append(steps, key)
case cty.GetAttrStep:
name, err := json.Marshal(s.Name)
if err != nil {
return nil, fmt.Errorf("Failed to marshal get attr step name %#v: %s", s.Name, err)
}
steps = append(steps, name)
default:
return nil, fmt.Errorf("Unsupported path step %#v (%t)", step, step)
}
}
jsonPath, err := json.Marshal(steps)
if err != nil {
return nil, err
}
jsonPaths = append(jsonPaths, jsonPath)
}
return json.Marshal(jsonPaths)
}

View File

@ -1,9 +1,11 @@
package jsonplan
import (
"encoding/json"
"reflect"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/zclconf/go-cty/cty"
)
@ -475,3 +477,43 @@ func TestSensitiveAsBool(t *testing.T) {
}
}
}
func TestEncodePaths(t *testing.T) {
tests := map[string]struct {
Input cty.PathSet
Want json.RawMessage
}{
"empty set": {
cty.NewPathSet(),
json.RawMessage(nil),
},
"index path with string and int steps": {
cty.NewPathSet(cty.IndexStringPath("boop").IndexInt(0)),
json.RawMessage(`[["boop",0]]`),
},
"get attr path with one step": {
cty.NewPathSet(cty.GetAttrPath("triggers")),
json.RawMessage(`[["triggers"]]`),
},
"multiple paths of different types": {
cty.NewPathSet(
cty.GetAttrPath("alpha").GetAttr("beta").GetAttr("gamma"),
cty.GetAttrPath("triggers").IndexString("name"),
cty.IndexIntPath(0).IndexInt(1).IndexInt(2).IndexInt(3),
),
json.RawMessage(`[["alpha","beta","gamma"],["triggers","name"],[0,1,2,3]]`),
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
got, err := encodePaths(test.Input)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if !cmp.Equal(got, test.Want) {
t.Errorf("wrong result:\n %v\n", cmp.Diff(got, test.Want))
}
})
}
}

View File

@ -40,7 +40,8 @@
"id": true
},
"after_sensitive": {},
"before_sensitive": {}
"before_sensitive": {},
"replace_paths": [["ami"]]
},
"action_reason": "replace_because_cannot_update"
}

View File

@ -490,7 +490,7 @@ A `<change-representation>` describes the change that will be made to the indica
// e.g. just scan the list for "delete" to recognize all three situations
// where the object will be deleted, allowing for any new deletion
// combinations that might be added in future.
"actions": ["update"]
"actions": ["update"],
// "before" and "after" are representations of the object value both before
// and after the action. For ["create"] and ["delete"] actions, either
@ -498,6 +498,35 @@ A `<change-representation>` describes the change that will be made to the indica
// after values are identical. The "after" value will be incomplete if there
// are values within it that won't be known until after apply.
"before": <value-representation>,
"after": <value-representation>
"after": <value-representation>,
// "after_unknown" is an object value with similar structure to "after", but
// with all unknown leaf values replaced with "true", and all known leaf
// values omitted. This can be combined with "after" to reconstruct a full
// value after the action, including values which will only be known after
// apply.
"after_unknown": {
"id": true
},
// "before_sensitive" and "after_sensitive" are object values with similar
// structure to "before" and "after", but with all sensitive leaf values
// replaced with true, and all non-sensitive leaf values omitted. These
// objects should be combined with "before" and "after" to prevent accidental
// display of sensitive values in user interfaces.
"before_sensitive": {},
"after_sensitive": {
"triggers": {
"boop": true
}
},
// "replace_paths" is an array of arrays representing a set of paths into the
// object value which resulted in the action being "replace". This will be
// omitted if the action is not replace, or if no paths caused the
// replacement (for example, if the resource was tainted). Each path
// consists of one or more steps, each of which will be a number or a
// string.
"replace_paths": [["triggers"]]
}
```