states/statefile: Serialize check results into state snapshots

This allows us to retain check results from one run into the next, so that
we can react to status changes between runs and potentially report e.g.
that a previously-failing check has now been fixed, or that a
previously-failing check is "still failing" so that an operator can get
a hint as to whether a problem is something they've just introduced or if
it was already an active problem before they made a change.
This commit is contained in:
Martin Atkins 2022-06-16 13:48:02 -07:00
parent 9e4861adbb
commit 6de3b1bd16
2 changed files with 151 additions and 3 deletions

View File

@ -104,6 +104,7 @@ func TestStatePersist(t *testing.T) {
"terraform_version": version.Version,
"outputs": map[string]interface{}{},
"resources": []interface{}{},
"check_results": nil,
},
},
},
@ -125,6 +126,7 @@ func TestStatePersist(t *testing.T) {
"terraform_version": version.Version,
"outputs": map[string]interface{}{},
"resources": []interface{}{},
"check_results": nil,
},
},
},
@ -149,6 +151,7 @@ func TestStatePersist(t *testing.T) {
},
},
"resources": []interface{}{},
"check_results": nil,
},
},
},
@ -173,6 +176,7 @@ func TestStatePersist(t *testing.T) {
},
},
"resources": []interface{}{},
"check_results": nil,
},
},
},
@ -204,6 +208,7 @@ func TestStatePersist(t *testing.T) {
},
},
"resources": []interface{}{},
"check_results": nil,
},
},
},
@ -379,6 +384,7 @@ func TestWriteStateForMigration(t *testing.T) {
"terraform_version": version.Version,
"outputs": map[string]interface{}{"foo": map[string]interface{}{"type": string("string"), "value": string("bar")}},
"resources": []interface{}{},
"check_results": nil,
},
},
force: true,
@ -397,6 +403,7 @@ func TestWriteStateForMigration(t *testing.T) {
"terraform_version": version.Version,
"outputs": map[string]interface{}{"foo": map[string]interface{}{"type": string("string"), "value": string("bar")}},
"resources": []interface{}{},
"check_results": nil,
},
},
force: true,
@ -533,6 +540,7 @@ func TestWriteStateForMigrationWithForcePushClient(t *testing.T) {
"terraform_version": version.Version,
"outputs": map[string]interface{}{"foo": map[string]interface{}{"type": string("string"), "value": string("bar")}},
"resources": []interface{}{},
"check_results": nil,
},
},
force: true,
@ -551,6 +559,7 @@ func TestWriteStateForMigrationWithForcePushClient(t *testing.T) {
"terraform_version": version.Version,
"outputs": map[string]interface{}{"foo": map[string]interface{}{"type": string("string"), "value": string("bar")}},
"resources": []interface{}{},
"check_results": nil,
},
},
force: true,

View File

@ -11,6 +11,7 @@ import (
ctyjson "github.com/zclconf/go-cty/cty/json"
"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/checks"
"github.com/hashicorp/terraform/internal/lang/marks"
"github.com/hashicorp/terraform/internal/states"
"github.com/hashicorp/terraform/internal/tfdiags"
@ -292,6 +293,17 @@ func prepareStateV4(sV4 *stateV4) (*File, tfdiags.Diagnostics) {
}
}
// Saved check results from the previous run, if any.
// We differentiate absense from an empty array here so that we can
// recognize if the previous run was with a version of Terraform that
// didn't support checks yet, or if there just weren't any checkable
// objects to record, in case that's important for certain messaging.
if sV4.CheckResults != nil {
var moreDiags tfdiags.Diagnostics
state.CheckResults, moreDiags = decodeCheckResultsV4(sV4.CheckResults)
diags = diags.Append(moreDiags)
}
file.State = state
return file, diags
}
@ -402,6 +414,10 @@ func writeStateV4(file *File, w io.Writer) tfdiags.Diagnostics {
}
}
if file.State.CheckResults != nil {
sV4.CheckResults = encodeCheckResultsV4(file.State.CheckResults)
}
sV4.normalize()
src, err := json.MarshalIndent(sV4, "", " ")
@ -496,6 +512,116 @@ func appendInstanceObjectStateV4(rs *states.Resource, is *states.ResourceInstanc
}), diags
}
func decodeCheckResultsV4(in []checkResultsV4) (*states.CheckResults, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
ret := &states.CheckResults{}
if len(in) == 0 {
return ret, diags
}
ret.ConfigResults = addrs.MakeMap[addrs.ConfigCheckable, *states.CheckResultAggregate]()
for _, aggrIn := range in {
// Some trickiness here: we only have an address parser for
// addrs.Checkable and not for addrs.ConfigCheckable, but that's okay
// because once we have an addrs.Checkable we can always derive an
// addrs.ConfigCheckable from it, and a ConfigCheckable should always
// be the same syntax as a Checkable with no index information and
// thus we can reuse the same parser for both here.
configAddrProxy, moreDiags := addrs.ParseCheckableStr(aggrIn.ConfigAddr)
diags = diags.Append(moreDiags)
if moreDiags.HasErrors() {
continue
}
configAddr := configAddrProxy.ConfigCheckable()
if configAddr.String() != configAddrProxy.String() {
// This is how we catch if the config address included index
// information that would be allowed in a Checkable but not
// in a ConfigCheckable.
diags = diags.Append(fmt.Errorf("invalid checkable config address %s", aggrIn.ConfigAddr))
continue
}
aggr := &states.CheckResultAggregate{
Status: decodeCheckStatusV4(aggrIn.Status),
}
if len(aggrIn.Objects) != 0 {
aggr.ObjectResults = addrs.MakeMap[addrs.Checkable, *states.CheckResultObject]()
for _, objectIn := range aggrIn.Objects {
objectAddr, moreDiags := addrs.ParseCheckableStr(objectIn.ObjectAddr)
diags = diags.Append(moreDiags)
if moreDiags.HasErrors() {
continue
}
obj := &states.CheckResultObject{
Status: decodeCheckStatusV4(objectIn.Status),
FailureMessages: objectIn.FailureMessages,
}
aggr.ObjectResults.Put(objectAddr, obj)
}
}
ret.ConfigResults.Put(configAddr, aggr)
}
return ret, diags
}
func encodeCheckResultsV4(in *states.CheckResults) []checkResultsV4 {
ret := make([]checkResultsV4, 0, in.ConfigResults.Len())
for _, configElem := range in.ConfigResults.Elems {
configResultsOut := checkResultsV4{
ConfigAddr: configElem.Key.String(),
Status: encodeCheckStatusV4(configElem.Value.Status),
}
for _, objectElem := range configElem.Value.ObjectResults.Elems {
configResultsOut.Objects = append(configResultsOut.Objects, checkResultsObjectV4{
ObjectAddr: objectElem.Key.String(),
Status: encodeCheckStatusV4(objectElem.Value.Status),
FailureMessages: objectElem.Value.FailureMessages,
})
}
ret = append(ret, configResultsOut)
}
return ret
}
func decodeCheckStatusV4(in string) checks.Status {
switch in {
case "pass":
return checks.StatusPass
case "fail":
return checks.StatusFail
case "error":
return checks.StatusError
default:
// We'll treat anything else as unknown just as a concession to
// forward-compatible parsing, in case a later version of Terraform
// introduces a new status.
return checks.StatusUnknown
}
}
func encodeCheckStatusV4(in checks.Status) string {
switch in {
case checks.StatusPass:
return "pass"
case checks.StatusFail:
return "fail"
case checks.StatusError:
return "error"
case checks.StatusUnknown:
return "unknown"
default:
panic(fmt.Sprintf("unsupported check status %s", in))
}
}
type stateV4 struct {
Version stateVersionV4 `json:"version"`
TerraformVersion string `json:"terraform_version"`
@ -503,6 +629,7 @@ type stateV4 struct {
Lineage string `json:"lineage"`
RootOutputs map[string]outputStateV4 `json:"outputs"`
Resources []resourceStateV4 `json:"resources"`
CheckResults []checkResultsV4 `json:"check_results"`
}
// normalize makes some in-place changes to normalize the way items are
@ -548,6 +675,18 @@ type instanceObjectStateV4 struct {
CreateBeforeDestroy bool `json:"create_before_destroy,omitempty"`
}
type checkResultsV4 struct {
ConfigAddr string `json:"config_addr"`
Status string `json:"status"`
Objects []checkResultsObjectV4 `json:"objects"`
}
type checkResultsObjectV4 struct {
ObjectAddr string `json:"object_addr"`
Status string `json:"status"`
FailureMessages []string `json:"failure_messages,omitempty"`
}
// stateVersionV4 is a weird special type we use to produce our hard-coded
// "version": 4 in the JSON serialization.
type stateVersionV4 struct{}