From a5b7394f9a0990a7c5f43e75fccabb1057a277f0 Mon Sep 17 00:00:00 2001 From: Alisdair McDiarmid Date: Tue, 4 May 2021 16:51:51 -0400 Subject: [PATCH] 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. --- command/jsonplan/plan.go | 64 +++++++++++++++++++ command/jsonplan/plan_test.go | 42 ++++++++++++ .../show-json/requires-replace/output.json | 3 +- website/docs/internals/json-format.html.md | 33 +++++++++- 4 files changed, 139 insertions(+), 3 deletions(-) diff --git a/command/jsonplan/plan.go b/command/jsonplan/plan.go index eeb634255..1db2067d4 100644 --- a/command/jsonplan/plan.go +++ b/command/jsonplan/plan.go @@ -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) +} diff --git a/command/jsonplan/plan_test.go b/command/jsonplan/plan_test.go index 9d43d1dc5..5656a6231 100644 --- a/command/jsonplan/plan_test.go +++ b/command/jsonplan/plan_test.go @@ -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)) + } + }) + } +} diff --git a/command/testdata/show-json/requires-replace/output.json b/command/testdata/show-json/requires-replace/output.json index b650e15a5..34dddcef2 100644 --- a/command/testdata/show-json/requires-replace/output.json +++ b/command/testdata/show-json/requires-replace/output.json @@ -40,7 +40,8 @@ "id": true }, "after_sensitive": {}, - "before_sensitive": {} + "before_sensitive": {}, + "replace_paths": [["ami"]] }, "action_reason": "replace_because_cannot_update" } diff --git a/website/docs/internals/json-format.html.md b/website/docs/internals/json-format.html.md index 52b080ef1..7741cd08a 100644 --- a/website/docs/internals/json-format.html.md +++ b/website/docs/internals/json-format.html.md @@ -490,7 +490,7 @@ A `` 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 `` 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": , - "after": + "after": , + + // "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"]] } ```