opentofu/internal/checks/state.go
Martin Atkins 9e4861adbb states: Two-level representation of check results
A significant goal of the design changes around checks in earlier commits
(with the introduction of package "checks") was to allow us to
differentiate between a configuration object that we didn't expand at all
due to an upstream error, which has _unknown_ check status, and a
configuration object that expanded to zero dynamic objects, which
therefore has a _passing_ check status.

However, our initial lowering of checks.State into states.CheckResults
stayed with the older model of just recording each leaf check in isolation,
without any tracking of the containers.

This commit therefore lightly reworks our representation of check results
in the state and plan with two main goals:
- The results are grouped by the static configuration object they came
  from, and we capture an aggregate status for each of those so that
  we can differentiate an unknown aggregate result from a passing
  aggregate result which has zero dynamic associated objects.
- The granularity of results is whole checkable objects rather than
  individual checks, because checkable objects have durable addresses
  between runs, but individual checks for an object are more of a
  syntactic convenience to make it easier for module authors to declare
  many independent conditions that each have their own error messages.

Since v1.2 exposed some details of our checks model into the JSON plan
output there are some unanswered questions here about how we can shift to
reporting in the two-level heirarchy described above. For now I've
preserved structural compatibility but not semantic compatibility: any
parser that was written against that format should still function but will
now see fewer results. We'll revisit this in a later commit and consider
other structures and what to do about our compatibility constraint on the
v1.2 structure.

Otherwise though, this is an internal-only change which preserves all of
the existing main behaviors of conditions as before, and just gets us
ready to build user-facing features in terms of this new structure.
2022-08-26 15:47:29 -07:00

291 lines
9.7 KiB
Go

package checks
import (
"fmt"
"sort"
"sync"
"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/configs"
)
// State is a container for state tracking of all of the the checks declared in
// a particular Terraform configuration and their current statuses.
//
// A State object is mutable during plan and apply operations but should
// otherwise be treated as a read-only snapshot of the status of checks
// at a particular moment.
//
// The checks State tracks a few different concepts:
// - configuration objects: items in the configuration which statically
// declare some checks associated with zero or more checkable objects.
// - checkable objects: dynamically-determined objects that are each
// associated with one configuration object.
// - checks: a single check that is declared as part of a configuration
// object and then resolved once for each of its associated checkable
// objects.
// - check statuses: the current state of a particular check associated
// with a particular checkable object.
//
// This container type is concurrency-safe for both reads and writes through
// its various methods.
type State struct {
mu sync.Mutex
statuses addrs.Map[addrs.ConfigCheckable, *configCheckableState]
failureMsgs addrs.Map[addrs.Check, string]
}
// configCheckableState is an internal part of type State that represents
// the evaluation status for a particular addrs.ConfigCheckable address.
//
// Its initial state, at the beginning of a run, is that it doesn't even know
// how many checkable objects will be dynamically-declared yet. Terraform Core
// will notify the State object of the associated Checkables once
// it has decided the appropriate expansion of that configuration object,
// and then will gradually report the results of each check once the graph
// walk reaches it.
//
// This must be accessed only while holding the mutex inside the associated
// State object.
type configCheckableState struct {
// checkTypes captures the expected number of checks of each type
// associated with object declared by this configuration construct. Since
// checks are statically declared (even though the checkable objects
// aren't) we can compute this only from the configuration.
checkTypes map[addrs.CheckType]int
// objects represents the set of dynamic checkable objects associated
// with this configuration construct. This is initially nil to represent
// that we don't know the objects yet, and is replaced by a non-nil map
// once Terraform Core reports the expansion of this configuration
// construct.
//
// The leaf Status values will initially be StatusUnknown
// and then gradually updated by Terraform Core as it visits the
// individual checkable objects and reports their status.
objects addrs.Map[addrs.Checkable, map[addrs.CheckType][]Status]
}
// NOTE: For the "Report"-prefixed methods that we use to gradually update
// the structure with results during a plan or apply operation, see the
// state_report.go file also in this package.
// NewState returns a new State object representing the check statuses of
// objects declared in the given configuration.
//
// The configuration determines which configuration objects and associated
// checks we'll be expecting to see, so that we can seed their statuses as
// all unknown until we see affirmative reports sent by the Report-prefixed
// methods on Checks.
func NewState(config *configs.Config) *State {
return &State{
statuses: initialStatuses(config),
}
}
// ConfigHasChecks returns true if and only if the given address refers to
// a configuration object that this State object is expecting to recieve
// statuses for.
//
// Other methods of Checks will typically panic if given a config address
// that would not have returned true from ConfigHasChecked.
func (c *State) ConfigHasChecks(addr addrs.ConfigCheckable) bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.statuses.Has(addr)
}
// AllConfigAddrs returns all of the addresses of all configuration objects
// that could potentially produce checkable objects at runtime.
//
// This is a good starting point for reporting on the outcome of all of the
// configured checks at the configuration level of granularity, e.g. for
// automated testing reports where we want to report the status of all
// configured checks even if the graph walk aborted before we reached any
// of their objects.
func (c *State) AllConfigAddrs() addrs.Set[addrs.ConfigCheckable] {
c.mu.Lock()
defer c.mu.Unlock()
return c.statuses.Keys()
}
// ObjectAddrs returns the addresses of individual checkable objects belonging
// to the configuration object with the given address.
//
// This will panic if the given address isn't a known configuration object
// that has checks.
func (c *State) ObjectAddrs(configAddr addrs.ConfigCheckable) addrs.Set[addrs.Checkable] {
c.mu.Lock()
defer c.mu.Unlock()
st, ok := c.statuses.GetOk(configAddr)
if !ok {
panic(fmt.Sprintf("unknown configuration object %s", configAddr))
}
ret := addrs.MakeSet[addrs.Checkable]()
for _, elem := range st.objects.Elems {
ret.Add(elem.Key)
}
return ret
}
// AggregateCheckStatus returns a summarization of all of the check results
// for a particular configuration object into a single status.
//
// The given address must refer to an object within the configuration that
// this Checks was instantiated from, or this method will panic.
func (c *State) AggregateCheckStatus(addr addrs.ConfigCheckable) Status {
c.mu.Lock()
defer c.mu.Unlock()
st, ok := c.statuses.GetOk(addr)
if !ok {
panic(fmt.Sprintf("request for status of unknown configuration object %s", addr))
}
if st.objects.Elems == nil {
// If we don't even know how many objects we have for this
// configuration construct then that summarizes as unknown.
// (Note: this is different than Elems being a non-nil empty map,
// which means that we know there are zero objects and therefore
// the aggregate result will be pass to pass below.)
return StatusUnknown
}
// Otherwise, our result depends on how many of our known objects are
// in each status.
errorCount := 0
failCount := 0
unknownCount := 0
for _, objects := range st.objects.Elems {
for _, checks := range objects.Value {
for _, status := range checks {
switch status {
case StatusPass:
// ok
case StatusFail:
failCount++
case StatusError:
errorCount++
default:
unknownCount++
}
}
}
}
return summarizeCheckStatuses(errorCount, failCount, unknownCount)
}
// ObjectCheckStatus returns a summarization of all of the check results
// for a particular checkable object into a single status.
//
// The given address must refer to a checkable object that Terraform Core
// previously reported while doing a graph walk, or this method will panic.
func (c *State) ObjectCheckStatus(addr addrs.Checkable) Status {
c.mu.Lock()
defer c.mu.Unlock()
configAddr := addr.ConfigCheckable()
st, ok := c.statuses.GetOk(configAddr)
if !ok {
panic(fmt.Sprintf("request for status of unknown object %s", addr))
}
if st.objects.Elems == nil {
panic(fmt.Sprintf("request for status of %s before establishing the checkable objects for %s", addr, configAddr))
}
checks, ok := st.objects.GetOk(addr)
if !ok {
panic(fmt.Sprintf("request for status of unknown object %s", addr))
}
errorCount := 0
failCount := 0
unknownCount := 0
for _, statuses := range checks {
for _, status := range statuses {
switch status {
case StatusPass:
// ok
case StatusFail:
failCount++
case StatusError:
errorCount++
default:
unknownCount++
}
}
}
return summarizeCheckStatuses(errorCount, failCount, unknownCount)
}
// ObjectFailureMessages returns the zero or more failure messages reported
// for the object with the given address.
//
// Failure messages are recorded only for checks whose status is StatusFail,
// but since this aggregates together the results of all of the checks
// on the given object it's possible for there to be a mixture of failures
// and errors at the same time, which would aggregate as StatusError in
// ObjectCheckStatus's result because errors are defined as "stronger"
// than failures.
func (c *State) ObjectFailureMessages(addr addrs.Checkable) []string {
var ret []string
configAddr := addr.ConfigCheckable()
st, ok := c.statuses.GetOk(configAddr)
if !ok {
panic(fmt.Sprintf("request for status of unknown object %s", addr))
}
if st.objects.Elems == nil {
panic(fmt.Sprintf("request for status of %s before establishing the checkable objects for %s", addr, configAddr))
}
checksByType, ok := st.objects.GetOk(addr)
if !ok {
panic(fmt.Sprintf("request for status of unknown object %s", addr))
}
for checkType, checks := range checksByType {
for i, status := range checks {
if status == StatusFail {
checkAddr := addrs.NewCheck(addr, checkType, i)
msg := c.failureMsgs.Get(checkAddr)
if msg != "" {
ret = append(ret, msg)
}
}
}
}
// We always return the messages in a lexical sort order just so that
// it'll be consistent between runs if we still have the same problems.
sort.Strings(ret)
return ret
}
func summarizeCheckStatuses(errorCount, failCount, unknownCount int) Status {
switch {
case errorCount > 0:
// If we saw any errors then we'll treat the whole thing as errored.
return StatusError
case failCount > 0:
// If anything failed then this whole configuration construct failed.
return StatusFail
case unknownCount > 0:
// If nothing failed but we still have unknowns then our outcome isn't
// known yet.
return StatusUnknown
default:
// If we have no failures and no unknowns then either we have all
// passes or no checkable objects at all, both of which summarize as
// a pass.
return StatusPass
}
}