diff --git a/internal/addrs/check.go b/internal/addrs/check.go index 3593eee911..eae7fcda66 100644 --- a/internal/addrs/check.go +++ b/internal/addrs/check.go @@ -21,21 +21,45 @@ type Check struct { Index int } +func NewCheck(container Checkable, typ CheckType, index int) Check { + return Check{ + Container: container, + Type: typ, + Index: index, + } +} + func (c Check) String() string { container := c.Container.String() switch c.Type { case ResourcePrecondition: - return fmt.Sprintf("%s.preconditions[%d]", container, c.Index) + return fmt.Sprintf("%s.precondition[%d]", container, c.Index) case ResourcePostcondition: - return fmt.Sprintf("%s.postconditions[%d]", container, c.Index) + return fmt.Sprintf("%s.postcondition[%d]", container, c.Index) case OutputPrecondition: - return fmt.Sprintf("%s.preconditions[%d]", container, c.Index) + return fmt.Sprintf("%s.precondition[%d]", container, c.Index) default: // This should not happen - return fmt.Sprintf("%s.conditions[%d]", container, c.Index) + return fmt.Sprintf("%s.condition[%d]", container, c.Index) } } +func (c Check) UniqueKey() UniqueKey { + return checkKey{ + ContainerKey: c.Container.UniqueKey(), + Type: c.Type, + Index: c.Index, + } +} + +type checkKey struct { + ContainerKey UniqueKey + Type CheckType + Index int +} + +func (k checkKey) uniqueKeySigil() {} + // Checkable is an interface implemented by all address types that can contain // condition blocks. type Checkable interface { diff --git a/internal/addrs/set.go b/internal/addrs/set.go index b04d76778e..70e9b4aaa0 100644 --- a/internal/addrs/set.go +++ b/internal/addrs/set.go @@ -9,6 +9,14 @@ package addrs // behavior elsewhere. type Set[T UniqueKeyer] map[UniqueKey]T +func MakeSet[T UniqueKeyer](elems ...T) Set[T] { + ret := Set[T](make(map[UniqueKey]T, len(elems))) + for _, elem := range elems { + ret.Add(elem) + } + return ret +} + // Has returns true if and only if the set includes the given address. func (s Set[T]) Has(addr T) bool { _, exists := s[addr.UniqueKey()] diff --git a/internal/checks/doc.go b/internal/checks/doc.go new file mode 100644 index 0000000000..b67aeba354 --- /dev/null +++ b/internal/checks/doc.go @@ -0,0 +1,5 @@ +// Package checks contains the models for representing various kinds of +// declarative condition checks that can be defined in a Terraform module +// and then evaluated and reported by Terraform Core during plan and apply +// operations. +package checks diff --git a/internal/checks/state.go b/internal/checks/state.go new file mode 100644 index 0000000000..f8712e68c0 --- /dev/null +++ b/internal/checks/state.go @@ -0,0 +1,295 @@ +package checks + +import ( + "fmt" + "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. +// +// 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] +} + +// 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() +} + +// AllObjectAddrs returns all of the addresses of individual checkable objects +// that were registered as part of the Terraform Core operation that this +// object is describing. +// +// If the corresponding Terraform Core operation returned an error or was +// using targeting to exclude some objects from the graph then this may not +// include declared objects for all of the expected configuration objects. +func (c *State) AllObjectAddrs() addrs.Set[addrs.Checkable] { + c.mu.Lock() + defer c.mu.Unlock() + + ret := addrs.MakeSet[addrs.Checkable]() + for _, st := range c.statuses.Elems { + for _, elem := range st.Value.objects.Elems { + ret.Add(elem.Key) + } + } + return ret +} + +// OverallCheckStatus returns a summarization of all of the check results +// across the entire configuration as a single unified status. +// +// This is a reasonable approximation for deciding an overall result for an +// automated testing scenario. +func (c *State) OverallCheckStatus() Status { + c.mu.Lock() + defer c.mu.Unlock() + + errorCount := 0 + failCount := 0 + unknownCount := 0 + + for _, elem := range c.statuses.Elems { + aggrStatus := c.aggregateCheckStatus(elem.Key) + switch aggrStatus { + case StatusPass: + // ok + case StatusFail: + failCount++ + case StatusError: + errorCount++ + default: + unknownCount++ + } + } + + return summarizeCheckStatuses(errorCount, failCount, unknownCount) +} + +// 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() + + return c.aggregateCheckStatus(addr) +} + +// aggregateCheckStatus is the main implementation of the public +// AggregateCheckStatus, which assumes that the caller has already acquired +// the mutex before calling. +func (c *State) aggregateCheckStatus(addr addrs.ConfigCheckable) Status { + 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. + 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) +} + +// AllCheckStatuses returns a flat map of all of the statuses of all of the +// checks associated with all of the objects know to the reciever. +// +// If the corresponding Terraform Core operation returned an error or was +// using targeting to exclude some objects from the graph then this may not +// include declared objects for all of the expected configuration objects. +func (c *State) AllCheckStatuses() addrs.Map[addrs.Check, Status] { + c.mu.Lock() + defer c.mu.Unlock() + + ret := addrs.MakeMap[addrs.Check, Status]() + + for _, configElem := range c.statuses.Elems { + for _, objectElem := range configElem.Value.objects.Elems { + objectAddr := objectElem.Key + for checkType, checks := range objectElem.Value { + for idx, status := range checks { + checkAddr := addrs.Check{ + Container: objectAddr, + Type: checkType, + Index: idx, + } + ret.Put(checkAddr, status) + } + } + } + } + + 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 + } +} diff --git a/internal/checks/state_init.go b/internal/checks/state_init.go new file mode 100644 index 0000000000..d7c59c8c51 --- /dev/null +++ b/internal/checks/state_init.go @@ -0,0 +1,71 @@ +package checks + +import ( + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/configs" +) + +func initialStatuses(cfg *configs.Config) addrs.Map[addrs.ConfigCheckable, *configCheckableState] { + ret := addrs.MakeMap[addrs.ConfigCheckable, *configCheckableState]() + + collectInitialStatuses(ret, cfg) + + return ret +} + +func collectInitialStatuses(into addrs.Map[addrs.ConfigCheckable, *configCheckableState], cfg *configs.Config) { + moduleAddr := cfg.Path + + for _, rc := range cfg.Module.ManagedResources { + addr := rc.Addr().InModule(moduleAddr) + collectInitialStatusForResource(into, addr, rc) + } + for _, rc := range cfg.Module.DataResources { + addr := rc.Addr().InModule(moduleAddr) + collectInitialStatusForResource(into, addr, rc) + } + + for _, oc := range cfg.Module.Outputs { + addr := oc.Addr().InModule(moduleAddr) + + ct := len(oc.Preconditions) + if ct == 0 { + // We just ignore output values that don't declare any checks. + continue + } + + st := &configCheckableState{} + + st.checkTypes = map[addrs.CheckType]int{ + addrs.OutputPrecondition: ct, + } + + into.Put(addr, st) + } + + // Must also visit child modules to collect everything + for _, child := range cfg.Children { + collectInitialStatuses(into, child) + } +} + +func collectInitialStatusForResource(into addrs.Map[addrs.ConfigCheckable, *configCheckableState], addr addrs.ConfigResource, rc *configs.Resource) { + if (len(rc.Preconditions) + len(rc.Postconditions)) == 0 { + // Don't bother with any resource that doesn't have at least + // one condition. + return + } + + st := &configCheckableState{ + checkTypes: make(map[addrs.CheckType]int), + } + + if ct := len(rc.Preconditions); ct > 0 { + st.checkTypes[addrs.ResourcePrecondition] = ct + } + if ct := len(rc.Postconditions); ct > 0 { + st.checkTypes[addrs.ResourcePostcondition] = ct + } + + into.Put(addr, st) +} diff --git a/internal/checks/state_report.go b/internal/checks/state_report.go new file mode 100644 index 0000000000..1940492539 --- /dev/null +++ b/internal/checks/state_report.go @@ -0,0 +1,88 @@ +package checks + +import ( + "fmt" + + "github.com/hashicorp/terraform/internal/addrs" +) + +// These are the "Report"-prefixed methods of Checks used by Terraform Core +// to gradually signal the results of checks during a plan or apply operation. + +// ReportCheckableObjects is the interface by which Terraform Core should +// tell the State object which specific checkable objects were declared +// by the given configuration object. +// +// This method will panic if the given configuration address isn't one known +// by this Checks to have pending checks, and if any of the given object +// addresses don't belong to the given configuration address. +func (c *State) ReportCheckableObjects(configAddr addrs.ConfigCheckable, objectAddrs addrs.Set[addrs.Checkable]) { + c.mu.Lock() + defer c.mu.Unlock() + + st, ok := c.statuses.GetOk(configAddr) + if !ok { + panic(fmt.Sprintf("checkable objects report for unknown configuration object %s", configAddr)) + } + if st.objects.Elems != nil { + // Can only report checkable objects once per configuration object + panic(fmt.Sprintf("duplicate checkable objects report for %s ", configAddr)) + } + + // At this point we pre-populate all of the check results as StatusUnknown, + // so that even if we never hear from Terraform Core again we'll still + // remember that these results were all pending. + st.objects = addrs.MakeMap[addrs.Checkable, map[addrs.CheckType][]Status]() + for _, objectAddr := range objectAddrs { + if gotConfigAddr := objectAddr.ConfigCheckable(); !addrs.Equivalent(configAddr, gotConfigAddr) { + // All of the given object addresses must belong to the specified configuration address + panic(fmt.Sprintf("%s belongs to %s, not %s", objectAddr, gotConfigAddr, configAddr)) + } + + checks := make(map[addrs.CheckType][]Status, len(st.checkTypes)) + for checkType, count := range st.checkTypes { + // NOTE: This is intentionally a slice of count of the zero value + // of Status, which is StatusUnknown to represent that we don't + // yet have a report for that particular check. + checks[checkType] = make([]Status, count) + } + + st.objects.Put(objectAddr, checks) + } +} + +// ReportCheckResult is the interface by which Terraform Core should tell the +// State object the result of a specific check for an object that was +// previously registered with ReportCheckableObjects. +// +// If the given object address doesn't match a previously-reported object, +// or if the check index is out of bounds for the number of checks expected +// of the given type, this method will panic to indicate a bug in the caller. +// +// This method will also panic if the specified check already had a known +// status; each check should have its result reported only once. +func (c *State) ReportCheckResult(objectAddr addrs.Checkable, checkType addrs.CheckType, index int, status Status) { + c.mu.Lock() + defer c.mu.Unlock() + + configAddr := objectAddr.ConfigCheckable() + + st, ok := c.statuses.GetOk(configAddr) + if !ok { + panic(fmt.Sprintf("checkable object status report for unknown configuration object %s", configAddr)) + } + + checks, ok := st.objects.GetOk(objectAddr) + if !ok { + panic(fmt.Sprintf("checkable object status report for unexpected checkable object %s", objectAddr)) + } + + if index >= len(checks[checkType]) { + panic(fmt.Sprintf("%s index %d out of range for %s", checkType, index, objectAddr)) + } + if checks[checkType][index] != StatusUnknown { + panic(fmt.Sprintf("duplicate status report for %s %s %d", objectAddr, checkType, index)) + } + + checks[checkType][index] = status +} diff --git a/internal/checks/state_test.go b/internal/checks/state_test.go new file mode 100644 index 0000000000..a5307cf3a3 --- /dev/null +++ b/internal/checks/state_test.go @@ -0,0 +1,241 @@ +package checks + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/configs/configload" + "github.com/hashicorp/terraform/internal/initwd" +) + +func TestChecksHappyPath(t *testing.T) { + const fixtureDir = "testdata/happypath" + loader, close := configload.NewLoaderForTests(t) + defer close() + inst := initwd.NewModuleInstaller(loader.ModulesDir(), nil) + _, instDiags := inst.InstallModules(context.Background(), fixtureDir, true, initwd.ModuleInstallHooksImpl{}) + if instDiags.HasErrors() { + t.Fatal(instDiags.Err()) + } + if err := loader.RefreshModules(); err != nil { + t.Fatalf("failed to refresh modules after installation: %s", err) + } + + ///////////////////////////////////////////////////////////////////////// + + cfg, hclDiags := loader.LoadConfig(fixtureDir) + if hclDiags.HasErrors() { + t.Fatalf("invalid configuration: %s", hclDiags.Error()) + } + + resourceA := addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "null_resource", + Name: "a", + }.InModule(addrs.RootModule) + resourceNoChecks := addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "null_resource", + Name: "no_checks", + }.InModule(addrs.RootModule) + resourceNonExist := addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "null_resource", + Name: "nonexist", + }.InModule(addrs.RootModule) + rootOutput := addrs.OutputValue{ + Name: "a", + }.InModule(addrs.RootModule) + moduleChild := addrs.RootModule.Child("child") + resourceB := addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "null_resource", + Name: "b", + }.InModule(moduleChild) + resourceC := addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "null_resource", + Name: "c", + }.InModule(moduleChild) + childOutput := addrs.OutputValue{ + Name: "b", + }.InModule(moduleChild) + + // First some consistency checks to make sure our configuration is the + // shape we are relying on it to be. + if addr := resourceA; cfg.Module.ResourceByAddr(addr.Resource) == nil { + t.Fatalf("configuration does not include %s", addr) + } + if addr := resourceB; cfg.Children["child"].Module.ResourceByAddr(addr.Resource) == nil { + t.Fatalf("configuration does not include %s", addr) + } + if addr := resourceNoChecks; cfg.Module.ResourceByAddr(addr.Resource) == nil { + t.Fatalf("configuration does not include %s", addr) + } + if addr := resourceNonExist; cfg.Module.ResourceByAddr(addr.Resource) != nil { + t.Fatalf("configuration includes %s, which is not supposed to exist", addr) + } + + ///////////////////////////////////////////////////////////////////////// + + checks := NewState(cfg) + + missing := 0 + if addr := resourceA; !checks.ConfigHasChecks(addr) { + t.Errorf("checks not detected for %s", addr) + missing++ + } + if addr := resourceB; !checks.ConfigHasChecks(addr) { + t.Errorf("checks not detected for %s", addr) + missing++ + } + if addr := resourceC; !checks.ConfigHasChecks(addr) { + t.Errorf("checks not detected for %s", addr) + missing++ + } + if addr := rootOutput; !checks.ConfigHasChecks(addr) { + t.Errorf("checks not detected for %s", addr) + missing++ + } + if addr := childOutput; !checks.ConfigHasChecks(addr) { + t.Errorf("checks not detected for %s", addr) + missing++ + } + if addr := resourceNoChecks; checks.ConfigHasChecks(addr) { + t.Errorf("checks detected for %s, even though it has none", addr) + } + if addr := resourceNonExist; checks.ConfigHasChecks(addr) { + t.Errorf("checks detected for %s, even though it doesn't exist", addr) + } + if missing > 0 { + t.Fatalf("missing some configuration objects we'd need for subsequent testing") + } + + ///////////////////////////////////////////////////////////////////////// + + // Everything should start with status unknown. + + if got, want := checks.OverallCheckStatus(), StatusUnknown; got != want { + t.Errorf("incorrect initial overall check status %s; want %s", got, want) + } + + { + wantConfigAddrs := addrs.MakeSet[addrs.ConfigCheckable]( + resourceA, + resourceB, + resourceC, + rootOutput, + childOutput, + ) + gotConfigAddrs := checks.AllConfigAddrs() + if diff := cmp.Diff(wantConfigAddrs, gotConfigAddrs); diff != "" { + t.Errorf("wrong detected config addresses\n%s", diff) + } + + for _, configAddr := range gotConfigAddrs { + if got, want := checks.AggregateCheckStatus(configAddr), StatusUnknown; got != want { + t.Errorf("incorrect initial aggregate check status for %s: %s, but want %s", configAddr, got, want) + } + } + } + + ///////////////////////////////////////////////////////////////////////// + + // The following are steps that would normally be done by Terraform Core + // as part of visiting checkable objects during the graph walk. We're + // simulating a likely sequence of calls here for testing purposes, but + // Terraform Core won't necessarily visit all of these in exactly the + // same order every time and so this is just one possible valid ordering + // of calls. + + resourceInstA := resourceA.Resource.Absolute(addrs.RootModuleInstance).Instance(addrs.NoKey) + rootOutputInst := rootOutput.OutputValue.Absolute(addrs.RootModuleInstance) + moduleChildInst := addrs.RootModuleInstance.Child("child", addrs.NoKey) + resourceInstB := resourceB.Resource.Absolute(moduleChildInst).Instance(addrs.NoKey) + resourceInstC0 := resourceC.Resource.Absolute(moduleChildInst).Instance(addrs.IntKey(0)) + resourceInstC1 := resourceC.Resource.Absolute(moduleChildInst).Instance(addrs.IntKey(1)) + childOutputInst := childOutput.OutputValue.Absolute(moduleChildInst) + + checks.ReportCheckableObjects(resourceA, addrs.MakeSet[addrs.Checkable](resourceInstA)) + checks.ReportCheckResult(resourceInstA, addrs.ResourcePrecondition, 0, StatusPass) + checks.ReportCheckResult(resourceInstA, addrs.ResourcePrecondition, 1, StatusPass) + checks.ReportCheckResult(resourceInstA, addrs.ResourcePostcondition, 0, StatusPass) + + checks.ReportCheckableObjects(resourceB, addrs.MakeSet[addrs.Checkable](resourceInstB)) + checks.ReportCheckResult(resourceInstB, addrs.ResourcePrecondition, 0, StatusPass) + + checks.ReportCheckableObjects(resourceC, addrs.MakeSet[addrs.Checkable](resourceInstC0, resourceInstC1)) + checks.ReportCheckResult(resourceInstC0, addrs.ResourcePostcondition, 0, StatusPass) + checks.ReportCheckResult(resourceInstC1, addrs.ResourcePostcondition, 0, StatusPass) + + checks.ReportCheckableObjects(childOutput, addrs.MakeSet[addrs.Checkable](childOutputInst)) + checks.ReportCheckResult(childOutputInst, addrs.OutputPrecondition, 0, StatusPass) + + checks.ReportCheckableObjects(rootOutput, addrs.MakeSet[addrs.Checkable](rootOutputInst)) + checks.ReportCheckResult(rootOutputInst, addrs.OutputPrecondition, 0, StatusPass) + + ///////////////////////////////////////////////////////////////////////// + + // This "section" is simulating what a check reporting UI might do in + // order to analyze the checks after the Terraform operation completes. + // Since we reported that everything passed above, we should be able to + // see that when we query the object in various ways. + if got, want := checks.OverallCheckStatus(), StatusPass; got != want { + t.Errorf("incorrect final overall check status %s; want %s", got, want) + } + + { + configCount := 0 + for _, configAddr := range checks.AllConfigAddrs() { + configCount++ + if got, want := checks.AggregateCheckStatus(configAddr), StatusPass; got != want { + t.Errorf("incorrect final aggregate check status for %s: %s, but want %s", configAddr, got, want) + } + } + if got, want := configCount, 5; got != want { + t.Errorf("incorrect number of known config addresses %d; want %d", got, want) + } + } + + { + wantObjAddrs := addrs.MakeSet[addrs.Checkable]( + resourceInstA, + rootOutputInst, + resourceInstB, + resourceInstC0, + resourceInstC1, + childOutputInst, + ) + gotObjAddrs := checks.AllObjectAddrs() + if diff := cmp.Diff(wantObjAddrs, gotObjAddrs); diff != "" { + t.Errorf("wrong object addresses\n%s", diff) + } + for _, addr := range gotObjAddrs { + if got, want := checks.ObjectCheckStatus(addr), StatusPass; got != want { + t.Errorf("incorrect final check status for object %s: %s, but want %s", addr, got, want) + } + } + } + + { + wantStatuses := addrs.MakeMap( + addrs.MakeMapElem(addrs.NewCheck(resourceInstA, addrs.ResourcePrecondition, 0), StatusPass), + addrs.MakeMapElem(addrs.NewCheck(resourceInstA, addrs.ResourcePrecondition, 1), StatusPass), + addrs.MakeMapElem(addrs.NewCheck(resourceInstA, addrs.ResourcePostcondition, 0), StatusPass), + + addrs.MakeMapElem(addrs.NewCheck(resourceInstB, addrs.ResourcePrecondition, 0), StatusPass), + + addrs.MakeMapElem(addrs.NewCheck(resourceInstC0, addrs.ResourcePostcondition, 0), StatusPass), + addrs.MakeMapElem(addrs.NewCheck(resourceInstC1, addrs.ResourcePostcondition, 0), StatusPass), + + addrs.MakeMapElem(addrs.NewCheck(rootOutputInst, addrs.OutputPrecondition, 0), StatusPass), + addrs.MakeMapElem(addrs.NewCheck(childOutputInst, addrs.OutputPrecondition, 0), StatusPass), + ) + gotStatuses := checks.AllCheckStatuses() + if diff := cmp.Diff(wantStatuses, gotStatuses); diff != "" { + t.Errorf("wrong check statuses\n%s", diff) + } + } +} diff --git a/internal/checks/status.go b/internal/checks/status.go new file mode 100644 index 0000000000..e95538609c --- /dev/null +++ b/internal/checks/status.go @@ -0,0 +1,74 @@ +package checks + +import ( + "fmt" + + "github.com/zclconf/go-cty/cty" +) + +// Status represents the status of an individual check associated with a +// checkable object. +type Status rune + +//go:generate go run golang.org/x/tools/cmd/stringer -type=Status + +const ( + // StatusUnknown represents that there is not yet a conclusive result + // for the check, either because we haven't yet visited its associated + // object or because the check condition itself depends on a value not + // yet known during planning. + StatusUnknown Status = 0 + // NOTE: Our implementation relies on StatusUnknown being the zero value + // of Status. + + // StatusPass represents that Terraform Core has evaluated the check's + // condition and it returned true, indicating success. + StatusPass Status = 'P' + + // StatusFail represents that Terraform Core has evaluated the check's + // condition and it returned false, indicating failure. + StatusFail Status = 'F' + + // StatusError represents that Terraform Core tried to evaluate the check's + // condition but encountered an error while evaluating the check expression. + // + // This is different than StatusFail because StatusFail indiciates that + // the condition was valid and returned false, whereas StatusError + // indicates that the condition was not valid at all. + StatusError Status = 'E' +) + +// StatusForCtyValue returns the Status value corresponding to the given +// cty Value, which must be one of either cty.True, cty.False, or +// cty.UnknownVal(cty.Bool) or else this function will panic. +// +// The current behavior of this function is: +// +// cty.True StatusPass +// cty.False StatusFail +// cty.UnknownVal(cty.Bool) StatusUnknown +// +// Any other input will panic. Note that there's no value that can produce +// StatusError, because in case of a condition error there will not typically +// be a result value at all. +func StatusForCtyValue(v cty.Value) Status { + if !v.Type().Equals(cty.Bool) { + panic(fmt.Sprintf("cannot use %s as check status", v.Type().FriendlyName())) + } + if v.IsNull() { + panic("cannot use null as check status") + } + + switch { + case v == cty.True: + return StatusPass + case v == cty.False: + return StatusFail + case !v.IsKnown(): + return StatusUnknown + default: + // Should be impossible to get here unless something particularly + // weird is going on, like a marked condition result. + panic(fmt.Sprintf("cannot use %#v as check status", v)) + } +} diff --git a/internal/checks/status_string.go b/internal/checks/status_string.go new file mode 100644 index 0000000000..3cee235aa9 --- /dev/null +++ b/internal/checks/status_string.go @@ -0,0 +1,39 @@ +// Code generated by "stringer -type=Status"; DO NOT EDIT. + +package checks + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[StatusUnknown-0] + _ = x[StatusPass-80] + _ = x[StatusFail-70] + _ = x[StatusError-69] +} + +const ( + _Status_name_0 = "StatusUnknown" + _Status_name_1 = "StatusErrorStatusFail" + _Status_name_2 = "StatusPass" +) + +var ( + _Status_index_1 = [...]uint8{0, 11, 21} +) + +func (i Status) String() string { + switch { + case i == 0: + return _Status_name_0 + case 69 <= i && i <= 70: + i -= 69 + return _Status_name_1[_Status_index_1[i]:_Status_index_1[i+1]] + case i == 80: + return _Status_name_2 + default: + return "Status(" + strconv.FormatInt(int64(i), 10) + ")" + } +} diff --git a/internal/checks/testdata/happypath/checks-happypath.tf b/internal/checks/testdata/happypath/checks-happypath.tf new file mode 100644 index 0000000000..4a6ca46fca --- /dev/null +++ b/internal/checks/testdata/happypath/checks-happypath.tf @@ -0,0 +1,32 @@ +resource "null_resource" "a" { + lifecycle { + precondition { + condition = null_resource.no_checks == "" + error_message = "Impossible." + } + precondition { + condition = null_resource.no_checks == "" + error_message = "Also impossible." + } + postcondition { + condition = null_resource.no_checks == "" + error_message = "Definitely not possible." + } + } +} + +resource "null_resource" "no_checks" { +} + +module "child" { + source = "./child" +} + +output "a" { + value = null_resource.a.id + + precondition { + condition = null_resource.a.id != "" + error_message = "A has no id." + } +} diff --git a/internal/checks/testdata/happypath/child/checks-happypath-child.tf b/internal/checks/testdata/happypath/child/checks-happypath-child.tf new file mode 100644 index 0000000000..d067bc2aa0 --- /dev/null +++ b/internal/checks/testdata/happypath/child/checks-happypath-child.tf @@ -0,0 +1,29 @@ +resource "null_resource" "b" { + lifecycle { + precondition { + condition = self.id == "" + error_message = "Impossible." + } + } +} + +resource "null_resource" "c" { + count = 2 + + lifecycle { + postcondition { + condition = self.id == "" + error_message = "Impossible." + } + } +} + +output "b" { + value = null_resource.b.id + + precondition { + condition = null_resource.b.id != "" + error_message = "B has no id." + } +} +