diff --git a/internal/command/jsonchecks/checks.go b/internal/command/jsonchecks/checks.go new file mode 100644 index 0000000000..8590da9be2 --- /dev/null +++ b/internal/command/jsonchecks/checks.go @@ -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. +} diff --git a/internal/command/jsonchecks/checks_test.go b/internal/command/jsonchecks/checks_test.go new file mode 100644 index 0000000000..fd8dba8b80 --- /dev/null +++ b/internal/command/jsonchecks/checks_test.go @@ -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) + } + }) + } +} diff --git a/internal/command/jsonchecks/doc.go b/internal/command/jsonchecks/doc.go new file mode 100644 index 0000000000..c495befb89 --- /dev/null +++ b/internal/command/jsonchecks/doc.go @@ -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 diff --git a/internal/command/jsonchecks/objects.go b/internal/command/jsonchecks/objects.go new file mode 100644 index 0000000000..472ef6922b --- /dev/null +++ b/internal/command/jsonchecks/objects.go @@ -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 +} diff --git a/internal/command/jsonchecks/status.go b/internal/command/jsonchecks/status.go new file mode 100644 index 0000000000..f55194aeb0 --- /dev/null +++ b/internal/command/jsonchecks/status.go @@ -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"`), +} diff --git a/internal/command/jsonplan/checks.go b/internal/command/jsonplan/checks.go new file mode 100644 index 0000000000..30cd8b3576 --- /dev/null +++ b/internal/command/jsonplan/checks.go @@ -0,0 +1 @@ +package jsonplan diff --git a/internal/command/jsonplan/condition.go b/internal/command/jsonplan/condition.go index 9f31fbabb3..dc7c4faf6d 100644 --- a/internal/command/jsonplan/condition.go +++ b/internal/command/jsonplan/condition.go @@ -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."`) diff --git a/internal/command/jsonplan/plan.go b/internal/command/jsonplan/plan.go index 1d597ef48b..048d68ea71 100644 --- a/internal/command/jsonplan/plan.go +++ b/internal/command/jsonplan/plan.go @@ -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) diff --git a/website/docs/internals/json-format.mdx b/website/docs/internals/json-format.mdx index a0194a08f9..a09adf5036 100644 --- a/website/docs/internals/json-format.mdx +++ b/website/docs/internals/json-format.mdx @@ -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": , } - } + }, + + // "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" } ``` @@ -604,3 +611,132 @@ A `` 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 `` 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.