command/jsonplan: Include new-style check results in JSON plan output

This is a new-shaped representation of check results which follows the
two-tiered structure of static objects and dynamic instances of objects,
thereby allowing consumers to see which checkable objects exist in the
configuration even if a dynamic evaluation error prevented actually
expanding them all to determine their declared instances.

Eventually we'll include this in the state too, but this initially adds it
only to the plan in order to replace the now-deprecated experimental
conditions result that was present but undocumented in Terraform v1.2.
This commit is contained in:
Martin Atkins 2022-07-22 18:56:11 -07:00
parent d63871f70d
commit fe7e6f970e
9 changed files with 586 additions and 5 deletions

View File

@ -0,0 +1,116 @@
package jsonchecks
import (
"encoding/json"
"fmt"
"sort"
"github.com/hashicorp/terraform/internal/states"
)
// MarshalCheckStates is the main entry-point for this package, which takes
// the top-level model object for checks in state and plan, and returns a
// JSON representation of it suitable for use in public integration points.
func MarshalCheckStates(results *states.CheckResults) []byte {
jsonResults := make([]checkResultStatic, 0, results.ConfigResults.Len())
for _, elem := range results.ConfigResults.Elems {
staticAddr := elem.Key
aggrResult := elem.Value
objects := make([]checkResultDynamic, 0, aggrResult.ObjectResults.Len())
for _, elem := range aggrResult.ObjectResults.Elems {
dynamicAddr := elem.Key
result := elem.Value
problems := make([]checkProblem, 0, len(result.FailureMessages))
for _, msg := range result.FailureMessages {
problems = append(problems, checkProblem{
Message: msg,
})
}
sort.Slice(problems, func(i, j int) bool {
return problems[i].Message < problems[j].Message
})
objects = append(objects, checkResultDynamic{
Address: makeDynamicObjectAddr(dynamicAddr),
Status: checkStatusForJSON(result.Status),
Problems: problems,
})
}
sort.Slice(objects, func(i, j int) bool {
return objects[i].Address["to_display"].(string) < objects[j].Address["to_display"].(string)
})
jsonResults = append(jsonResults, checkResultStatic{
Address: makeStaticObjectAddr(staticAddr),
Status: checkStatusForJSON(aggrResult.Status),
Instances: objects,
})
}
sort.Slice(jsonResults, func(i, j int) bool {
return jsonResults[i].Address["to_display"].(string) < jsonResults[j].Address["to_display"].(string)
})
ret, err := json.Marshal(jsonResults)
if err != nil {
// We totally control the input to json.Marshal, so any error here
// is a bug in the code above.
panic(fmt.Sprintf("invalid input to json.Marshal: %s", err))
}
return ret
}
// checkResultStatic is the container for the static, configuration-driven
// idea of "checkable object" -- a resource block with conditions, for example --
// which ensures that we can always say _something_ about each checkable
// object in the configuration even if Terraform Core encountered an error
// before being able to determine the dynamic instances of the checkable object.
type checkResultStatic struct {
// Address is the address of the checkable object this result relates to.
Address staticObjectAddr `json:"address"`
// Status is the aggregate status for all of the dynamic objects belonging
// to this static object.
Status checkStatus `json:"status"`
// Instances contains the results for each individual dynamic object that
// belongs to this static object.
Instances []checkResultDynamic `json:"instances,omitempty"`
}
// checkResultDynamic describes the check result for a dynamic object, which
// results from Terraform Core evaluating the "expansion" (e.g. count or for_each)
// of the containing object or its own containing module(s).
type checkResultDynamic struct {
// Address augments the Address of the containing checkResultStatic with
// instance-specific extra properties or overridden properties.
Address dynamicObjectAddr `json:"address"`
// Status is the status for this specific dynamic object.
Status checkStatus `json:"status"`
// Problems describes some optional details associated with a failure
// status, describing what fails.
//
// This does not include the errors for status "error", because Terraform
// Core emits those separately as normal diagnostics. However, if a
// particular object has a mixture of conditions that failed and conditions
// that were invalid then status can be "error" while simultaneously
// returning problems in this property.
Problems []checkProblem `json:"problems,omitempty"`
}
// checkProblem describes one of potentially several problems that led to
// a check being classified as status "fail".
type checkProblem struct {
// Message is the condition error message provided by the author.
Message string `json:"message"`
// We don't currently have any other problem-related data, but this is
// intentionally an object to allow us to add other data over time, such
// as the source location where the failing condition was defined.
}

View File

@ -0,0 +1,206 @@
package jsonchecks
import (
"encoding/json"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/checks"
"github.com/hashicorp/terraform/internal/states"
)
func TestMarshalCheckStates(t *testing.T) {
resourceAAddr := addrs.ConfigCheckable(addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test",
Name: "a",
}.InModule(addrs.RootModule))
resourceAInstAddr := addrs.Checkable(addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test",
Name: "a",
}.Instance(addrs.StringKey("foo")).Absolute(addrs.RootModuleInstance))
moduleChildAddr := addrs.RootModuleInstance.Child("child", addrs.IntKey(0))
resourceBAddr := addrs.ConfigCheckable(addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test",
Name: "b",
}.InModule(moduleChildAddr.Module()))
resourceBInstAddr := addrs.Checkable(addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test",
Name: "b",
}.Instance(addrs.NoKey).Absolute(moduleChildAddr))
outputAAddr := addrs.ConfigCheckable(addrs.OutputValue{Name: "a"}.InModule(addrs.RootModule))
outputAInstAddr := addrs.Checkable(addrs.OutputValue{Name: "a"}.Absolute(addrs.RootModuleInstance))
outputBAddr := addrs.ConfigCheckable(addrs.OutputValue{Name: "b"}.InModule(moduleChildAddr.Module()))
outputBInstAddr := addrs.Checkable(addrs.OutputValue{Name: "b"}.Absolute(moduleChildAddr))
tests := map[string]struct {
Input *states.CheckResults
Want any
}{
"empty": {
&states.CheckResults{},
[]any{},
},
"failures": {
&states.CheckResults{
ConfigResults: addrs.MakeMap(
addrs.MakeMapElem(resourceAAddr, &states.CheckResultAggregate{
Status: checks.StatusFail,
ObjectResults: addrs.MakeMap(
addrs.MakeMapElem(resourceAInstAddr, &states.CheckResultObject{
Status: checks.StatusFail,
FailureMessages: []string{
"Not enough boops.",
"Too many beeps.",
},
}),
),
}),
addrs.MakeMapElem(resourceBAddr, &states.CheckResultAggregate{
Status: checks.StatusFail,
ObjectResults: addrs.MakeMap(
addrs.MakeMapElem(resourceBInstAddr, &states.CheckResultObject{
Status: checks.StatusFail,
FailureMessages: []string{
"Splines are too pointy.",
},
}),
),
}),
addrs.MakeMapElem(outputAAddr, &states.CheckResultAggregate{
Status: checks.StatusFail,
ObjectResults: addrs.MakeMap(
addrs.MakeMapElem(outputAInstAddr, &states.CheckResultObject{
Status: checks.StatusFail,
}),
),
}),
addrs.MakeMapElem(outputBAddr, &states.CheckResultAggregate{
Status: checks.StatusFail,
ObjectResults: addrs.MakeMap(
addrs.MakeMapElem(outputBInstAddr, &states.CheckResultObject{
Status: checks.StatusFail,
FailureMessages: []string{
"Not object-oriented enough.",
},
}),
),
}),
),
},
[]any{
map[string]any{
"address": map[string]any{
"kind": "output_value",
"module": "module.child",
"name": "b",
"to_display": "module.child.output.b",
},
"instances": []any{
map[string]any{
"address": map[string]any{
"module": "module.child[0]",
"to_display": "module.child[0].output.b",
},
"problems": []any{
map[string]any{
"message": "Not object-oriented enough.",
},
},
"status": "fail",
},
},
"status": "fail",
},
map[string]any{
"address": map[string]any{
"kind": "resource",
"mode": "managed",
"module": "module.child",
"name": "b",
"to_display": "module.child.test.b",
"type": "test",
},
"instances": []any{
map[string]any{
"address": map[string]any{
"module": "module.child[0]",
"to_display": "module.child[0].test.b",
},
"problems": []any{
map[string]any{
"message": "Splines are too pointy.",
},
},
"status": "fail",
},
},
"status": "fail",
},
map[string]any{
"address": map[string]any{
"kind": "output_value",
"name": "a",
"to_display": "output.a",
},
"instances": []any{
map[string]any{
"address": map[string]any{
"to_display": "output.a",
},
"status": "fail",
},
},
"status": "fail",
},
map[string]any{
"address": map[string]any{
"kind": "resource",
"mode": "managed",
"name": "a",
"to_display": "test.a",
"type": "test",
},
"instances": []any{
map[string]any{
"address": map[string]any{
"to_display": `test.a["foo"]`,
"instance_key": "foo",
},
"problems": []any{
map[string]any{
"message": "Not enough boops.",
},
map[string]any{
"message": "Too many beeps.",
},
},
"status": "fail",
},
},
"status": "fail",
},
},
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
gotBytes := MarshalCheckStates(test.Input)
var got any
err := json.Unmarshal(gotBytes, &got)
if err != nil {
t.Fatal(err)
}
if diff := cmp.Diff(test.Want, got); diff != "" {
t.Errorf("wrong result\n%s", diff)
}
})
}
}

View File

@ -0,0 +1,4 @@
// Package jsonchecks implements the common JSON representation of check
// results/statuses that we use across both the JSON plan and JSON state
// representations.
package jsonchecks

View File

@ -0,0 +1,69 @@
package jsonchecks
import (
"fmt"
"github.com/hashicorp/terraform/internal/addrs"
)
type staticObjectAddr map[string]interface{}
func makeStaticObjectAddr(addr addrs.ConfigCheckable) staticObjectAddr {
ret := map[string]interface{}{
"to_display": addr.String(),
}
switch addr := addr.(type) {
case addrs.ConfigResource:
ret["kind"] = "resource"
switch addr.Resource.Mode {
case addrs.ManagedResourceMode:
ret["mode"] = "managed"
case addrs.DataResourceMode:
ret["mode"] = "data"
default:
panic(fmt.Sprintf("unsupported resource mode %#v", addr.Resource.Mode))
}
ret["type"] = addr.Resource.Type
ret["name"] = addr.Resource.Name
if !addr.Module.IsRoot() {
ret["module"] = addr.Module.String()
}
case addrs.ConfigOutputValue:
ret["kind"] = "output_value"
ret["name"] = addr.OutputValue.Name
if !addr.Module.IsRoot() {
ret["module"] = addr.Module.String()
}
default:
panic(fmt.Sprintf("unsupported ConfigCheckable implementation %T", addr))
}
return ret
}
type dynamicObjectAddr map[string]interface{}
func makeDynamicObjectAddr(addr addrs.Checkable) dynamicObjectAddr {
ret := map[string]interface{}{
"to_display": addr.String(),
}
switch addr := addr.(type) {
case addrs.AbsResourceInstance:
if !addr.Module.IsRoot() {
ret["module"] = addr.Module.String()
}
if addr.Resource.Key != addrs.NoKey {
ret["instance_key"] = addr.Resource.Key
}
case addrs.AbsOutputValue:
if !addr.Module.IsRoot() {
ret["module"] = addr.Module.String()
}
default:
panic(fmt.Sprintf("unsupported Checkable implementation %T", addr))
}
return ret
}

View File

@ -0,0 +1,27 @@
package jsonchecks
import (
"fmt"
"github.com/hashicorp/terraform/internal/checks"
)
type checkStatus []byte
func checkStatusForJSON(s checks.Status) checkStatus {
if ret, ok := checkStatuses[s]; ok {
return ret
}
panic(fmt.Sprintf("unsupported check status %#v", s))
}
func (s checkStatus) MarshalJSON() ([]byte, error) {
return []byte(s), nil
}
var checkStatuses = map[checks.Status]checkStatus{
checks.StatusPass: checkStatus(`"pass"`),
checks.StatusFail: checkStatus(`"fail"`),
checks.StatusError: checkStatus(`"error"`),
checks.StatusUnknown: checkStatus(`"unknown"`),
}

View File

@ -0,0 +1 @@
package jsonplan

View File

@ -5,10 +5,17 @@ package jsonplan
// This no longer really fits how Terraform is modelling checks -- we're now
// treating check status as a whole-object thing rather than an individual
// condition thing -- but we've preserved this for now to remain as compatible
// as possible with the interface we'd documented as part of the Terraform v1.2
// release, before we'd really solidified the use-cases for checks outside
// of just making a single plan and apply operation fail with an error.
// as possible with the interface we'd experimentally-implemented but not
// documented in the Terraform v1.2 release, before we'd really solidified the
// use-cases for checks outside of just making a single plan and apply
// operation fail with an error.
type conditionResult struct {
// This is a weird "pseudo-comment" noting that we're deprecating this
// not-previously-documented, experimental representation of conditions
// in favor of the "checks" property which better fits Terraform Core's
// modelling of checks.
DeprecationNotice conditionResultDeprecationNotice `json:"//"`
// Address is the absolute address of the condition's containing object.
Address string `json:"address,omitempty"`
@ -27,3 +34,11 @@ type conditionResult struct {
// present if the condition fails.
ErrorMessage string `json:"error_message,omitempty"`
}
type conditionResultDeprecationNotice struct{}
func (n conditionResultDeprecationNotice) MarshalJSON() ([]byte, error) {
return conditionResultDeprecationNoticeJSON, nil
}
var conditionResultDeprecationNoticeJSON = []byte(`"This previously-experimental representation of conditions is deprecated and will be removed in Terraform v1.4. Use the 'checks' property instead."`)

View File

@ -10,6 +10,7 @@ import (
"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/checks"
"github.com/hashicorp/terraform/internal/command/jsonchecks"
"github.com/hashicorp/terraform/internal/command/jsonconfig"
"github.com/hashicorp/terraform/internal/command/jsonstate"
"github.com/hashicorp/terraform/internal/configs"
@ -41,6 +42,7 @@ type plan struct {
Config json.RawMessage `json:"configuration,omitempty"`
RelevantAttributes []resourceAttr `json:"relevant_attributes,omitempty"`
Conditions []conditionResult `json:"condition_results,omitempty"`
Checks json.RawMessage `json:"checks,omitempty"`
}
func newPlan() *plan {
@ -180,12 +182,17 @@ func Marshal(
return nil, fmt.Errorf("error in marshaling output changes: %s", err)
}
// output.Conditions
// output.Conditions (deprecated in favor of Checks, below)
err = output.marshalCheckResults(p.Checks)
if err != nil {
return nil, fmt.Errorf("error in marshaling check results: %s", err)
}
// output.Checks
if p.Checks != nil && p.Checks.ConfigResults.Len() > 0 {
output.Checks = jsonchecks.MarshalCheckStates(p.Checks)
}
// output.PriorState
if sf != nil && !sf.State.Empty() {
output.PriorState, err = jsonstate.Marshal(sf, schemas)

View File

@ -45,6 +45,7 @@ The JSON output format consists of the following objects and sub-objects:
- [Expression Representation](#expression-representation) — A sub-object of a configuration representation that describes an unevaluated expression.
- [Block Expressions Representation](#block-expressions-representation) — A sub-object of a configuration representation that describes the expressions nested inside a block.
- [Change Representation](#change-representation) — A sub-object of plan output that describes changes to an object.
- [Checks Representation](#checks-representation) — A property of both the plan and state representations that describes the current status of any checks (e.g. preconditions and postconditions) in the configuration.
## State Representation
@ -224,7 +225,13 @@ For ease of consumption by callers, the plan representation includes a partial r
// fully accurate, but the "after" value will always be correct.
"change": <change-representation>,
}
}
},
// "checks" describes the partial results for any checkable objects, such as
// resources with postconditions, with as much information as Terraform can
// recognize at plan time. Some objects will have status "unknown" to
// indicate that their status will only be determined after applying the plan.
"checks" <checks-representation>
}
```
@ -604,3 +611,132 @@ A `<change-representation>` describes the change to the indicated object.
"replace_paths": [["triggers"]]
}
```
## Checks Representation
~> **Warning:** The JSON representation of "checks" is currently experimental
and some details may change in future Terraform versions based on feedback,
even in minor releases of Terraform CLI.
A `<checks-representation>` describes the current state of a checkable object in the configuration. For example, a resource with one or more preconditions or postconditions is an example of a checkable object, and its check state represents the results of those conditions.
```javascript
[
{
// "address" describes the address of the checkable object whose status
// this object is describing.
"address": {
// "kind" specifies what kind of checkable object this is. Different
// kinds of object will have different additional properties inside the
// address object, but all kinds include both "kind" and "to_display".
// Currently the two valid kinds are "resource" and "output_value", but
// additional kinds may be added in future Terraform versions.
"kind": "resource",
// "to_display" contains an opaque string representation of the address
// of the object that is suitable for display in a UI. For consumers that
// have special handling depending on the value of "kind", this property
// is a good fallback to use when the application doesn't recognize the
// "kind" value.
"to_display": "aws_instance.example",
// "mode" is included for kind "resource" only, and specifies the resource
// mode which can either be "managed" (for "resource" blocks) or "data"
// (for "data" blocks).
"mode": "managed",
// "type" is included for kind "resource" only, and specifies the resource
// type.
"type": "aws_instance",
// "name" is the local name of the object. For a resource this is the
// second label in the resource block header, and for an output value
// this is the single label in the output block header.
"name": "example",
// "module" is included if the object belongs to a module other than
// the root module, and provides an opaque string representation of the
// module this object belongs to. This example is of a root module
// resource and so "module" is not included.
}
// "status" is the aggregate status of all of the instances of the object
// being described by this object.
// The possible values are "pass", "fail", "error", and "unknown".
"status": "fail",
// "instances" describes the current status of each of the instances of
// the object being described. An object can have multiple instances if
// it is either a resource which has "count" or "for_each" set, or if
// it's contained within a module that has "count" or "for_each" set.
//
// If "instances" is empty or omitted, that can either mean that the object
// has no instances at all (e.g. count = 0) or that an error blocked
// evaluation of the repetition argument. You can distinguish these cases
// using the "status" property, which will be "pass" or "error" for a
// zero-instance object and "unknown" for situations where an error blocked
// evalation.
"instances": [
{
// "address" is an object similar to the property of the same name in
// the containing object. Merge the instance-level address into the
// object-level address, overwriting any conflicting property names,
// to create a full description of the instance's address.
"address": {
// "to_display" overrides the property of the same name in the main
// object's address, to include any module instance or resource
// instance keys that uniquely identify this instance.
"to_display": "aws_instance.example[0]",
// "instance_key" is included for resources only and specifies the
// resource-level instance key, which can either be a number or a
// string. Omitted for single-instance resources.
"instance_key": 0,
// "module" is included if the object belongs to a module other than
// the root module, and provides an opaque string representation of the
// module instance this object belongs to.
},
// "status" describes the result of running the configured checks
// against this particular instance of the object, with the same
// possible values as the "status" in the parent object.
//
// "fail" means that the condition evaluated successfully but returned
// false, while "error" means that the condition expression itself
// was invalid.
"status": "fail",
// "problems" might be included for statuses "fail" or "error", in
// which case it describes the individual conditions that failed for
// this instance, if any.
// When a condition expression is invalid, Terraform returns that as
// a normal error message rather than as a problem in this list.
"problems": [
{
// "message" is the string that resulted from evaluating the
// error_message argument of the failing condition.
"message": "Server does not have a public IPv6 address."
}
]
},
]
}
]
```
The "checks" model includes both static checkable objects and instances of
those objects to ensure that the set of checkable objects will be consistent
even if an error prevents full evaluation of the configuration. Any object
in the configuration which has associated checks, such as a resource with
preconditions or postconditions, will always be included as a checkable object
even if a runtime error prevents Terraform from evaluating its "count" or
"for_each" argument and therefore determining which instances of that object
exist dynamically.
When summarizing checks in a UI, we recommend preferring to list only the
individual instances and typically ignoring the top-level objects altogether.
However, in any case where an object has _zero_ instances, the UI should show
the top-level object instead to serve as a placeholder so that the user can
see that Terraform recognized the existence of the checks, even if it wasn't
able to evaluate them on the most recent run.