Skip to content

Commit b59bffa

Browse files
committed
core: Evaluate pre/postconditions during validate
During the validation walk, we attempt to proactively evaluate check rule condition and error message expressions. This will help catch some errors as early as possible. At present, resource values in the validation walk are of dynamic type. This means that any references to resources will cause validation to be delayed, rather than presenting useful errors. Validation may still catch other errors, and any future changes which cause better type propagation will result in better validation too.
1 parent b06fe04 commit b59bffa

File tree

2 files changed

+316
-0
lines changed

2 files changed

+316
-0
lines changed

internal/terraform/context_validate_test.go

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2095,3 +2095,293 @@ func TestContext2Validate_nonNullableVariableDefaultValidation(t *testing.T) {
20952095
t.Fatal(diags.ErrWithWarnings())
20962096
}
20972097
}
2098+
2099+
func TestContext2Validate_precondition_good(t *testing.T) {
2100+
p := testProvider("aws")
2101+
p.GetProviderSchemaResponse = getProviderSchemaResponseFromProviderSchema(&ProviderSchema{
2102+
ResourceTypes: map[string]*configschema.Block{
2103+
"aws_instance": {
2104+
Attributes: map[string]*configschema.Attribute{
2105+
"foo": {Type: cty.String, Optional: true},
2106+
},
2107+
},
2108+
},
2109+
})
2110+
m := testModuleInline(t, map[string]string{
2111+
"main.tf": `
2112+
terraform {
2113+
experiments = [preconditions_postconditions]
2114+
}
2115+
2116+
variable "input" {
2117+
type = string
2118+
default = "foo"
2119+
}
2120+
2121+
resource "aws_instance" "test" {
2122+
foo = var.input
2123+
2124+
lifecycle {
2125+
precondition {
2126+
condition = length(var.input) > 0
2127+
error_message = "Input cannot be empty."
2128+
}
2129+
}
2130+
}
2131+
`,
2132+
})
2133+
2134+
ctx := testContext2(t, &ContextOpts{
2135+
Providers: map[addrs.Provider]providers.Factory{
2136+
addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p),
2137+
},
2138+
})
2139+
2140+
diags := ctx.Validate(m)
2141+
if diags.HasErrors() {
2142+
t.Fatal(diags.ErrWithWarnings())
2143+
}
2144+
}
2145+
2146+
func TestContext2Validate_precondition_badCondition(t *testing.T) {
2147+
p := testProvider("aws")
2148+
p.GetProviderSchemaResponse = getProviderSchemaResponseFromProviderSchema(&ProviderSchema{
2149+
ResourceTypes: map[string]*configschema.Block{
2150+
"aws_instance": {
2151+
Attributes: map[string]*configschema.Attribute{
2152+
"foo": {Type: cty.String, Optional: true},
2153+
},
2154+
},
2155+
},
2156+
})
2157+
m := testModuleInline(t, map[string]string{
2158+
"main.tf": `
2159+
terraform {
2160+
experiments = [preconditions_postconditions]
2161+
}
2162+
2163+
variable "input" {
2164+
type = string
2165+
default = "foo"
2166+
}
2167+
2168+
resource "aws_instance" "test" {
2169+
foo = var.input
2170+
2171+
lifecycle {
2172+
precondition {
2173+
condition = length(one(var.input)) == 1
2174+
error_message = "You can't do that."
2175+
}
2176+
}
2177+
}
2178+
`,
2179+
})
2180+
2181+
ctx := testContext2(t, &ContextOpts{
2182+
Providers: map[addrs.Provider]providers.Factory{
2183+
addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p),
2184+
},
2185+
})
2186+
2187+
diags := ctx.Validate(m)
2188+
if !diags.HasErrors() {
2189+
t.Fatalf("succeeded; want error")
2190+
}
2191+
if got, want := diags.Err().Error(), "Invalid function argument"; !strings.Contains(got, want) {
2192+
t.Errorf("unexpected error.\ngot: %s\nshould contain: %q", got, want)
2193+
}
2194+
}
2195+
2196+
func TestContext2Validate_precondition_badErrorMessage(t *testing.T) {
2197+
p := testProvider("aws")
2198+
p.GetProviderSchemaResponse = getProviderSchemaResponseFromProviderSchema(&ProviderSchema{
2199+
ResourceTypes: map[string]*configschema.Block{
2200+
"aws_instance": {
2201+
Attributes: map[string]*configschema.Attribute{
2202+
"foo": {Type: cty.String, Optional: true},
2203+
},
2204+
},
2205+
},
2206+
})
2207+
m := testModuleInline(t, map[string]string{
2208+
"main.tf": `
2209+
terraform {
2210+
experiments = [preconditions_postconditions]
2211+
}
2212+
2213+
variable "input" {
2214+
type = string
2215+
default = "foo"
2216+
}
2217+
2218+
resource "aws_instance" "test" {
2219+
foo = var.input
2220+
2221+
lifecycle {
2222+
precondition {
2223+
condition = var.input != "foo"
2224+
error_message = "This is a bad use of a function: ${one(var.input)}."
2225+
}
2226+
}
2227+
}
2228+
`,
2229+
})
2230+
2231+
ctx := testContext2(t, &ContextOpts{
2232+
Providers: map[addrs.Provider]providers.Factory{
2233+
addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p),
2234+
},
2235+
})
2236+
2237+
diags := ctx.Validate(m)
2238+
if !diags.HasErrors() {
2239+
t.Fatalf("succeeded; want error")
2240+
}
2241+
if got, want := diags.Err().Error(), "Invalid function argument"; !strings.Contains(got, want) {
2242+
t.Errorf("unexpected error.\ngot: %s\nshould contain: %q", got, want)
2243+
}
2244+
}
2245+
2246+
func TestContext2Validate_postcondition_good(t *testing.T) {
2247+
p := testProvider("aws")
2248+
p.GetProviderSchemaResponse = getProviderSchemaResponseFromProviderSchema(&ProviderSchema{
2249+
ResourceTypes: map[string]*configschema.Block{
2250+
"aws_instance": {
2251+
Attributes: map[string]*configschema.Attribute{
2252+
"foo": {Type: cty.String, Optional: true},
2253+
},
2254+
},
2255+
},
2256+
})
2257+
m := testModuleInline(t, map[string]string{
2258+
"main.tf": `
2259+
terraform {
2260+
experiments = [preconditions_postconditions]
2261+
}
2262+
2263+
resource "aws_instance" "test" {
2264+
foo = "foo"
2265+
2266+
lifecycle {
2267+
postcondition {
2268+
condition = length(self.foo) > 0
2269+
error_message = "Input cannot be empty."
2270+
}
2271+
}
2272+
}
2273+
`,
2274+
})
2275+
2276+
ctx := testContext2(t, &ContextOpts{
2277+
Providers: map[addrs.Provider]providers.Factory{
2278+
addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p),
2279+
},
2280+
})
2281+
2282+
diags := ctx.Validate(m)
2283+
if diags.HasErrors() {
2284+
t.Fatal(diags.ErrWithWarnings())
2285+
}
2286+
}
2287+
2288+
func TestContext2Validate_postcondition_badCondition(t *testing.T) {
2289+
p := testProvider("aws")
2290+
p.GetProviderSchemaResponse = getProviderSchemaResponseFromProviderSchema(&ProviderSchema{
2291+
ResourceTypes: map[string]*configschema.Block{
2292+
"aws_instance": {
2293+
Attributes: map[string]*configschema.Attribute{
2294+
"foo": {Type: cty.String, Optional: true},
2295+
},
2296+
},
2297+
},
2298+
})
2299+
// This postcondition's condition expression does not refer to self, which
2300+
// is unrealistic. This is because at the time of writing the test, self is
2301+
// always an unknown value of dynamic type during validation. As a result,
2302+
// validation of conditions which refer to resource arguments is not
2303+
// possible until plan time. For now we exercise the code by referring to
2304+
// an input variable.
2305+
m := testModuleInline(t, map[string]string{
2306+
"main.tf": `
2307+
terraform {
2308+
experiments = [preconditions_postconditions]
2309+
}
2310+
2311+
variable "input" {
2312+
type = string
2313+
default = "foo"
2314+
}
2315+
2316+
resource "aws_instance" "test" {
2317+
foo = var.input
2318+
2319+
lifecycle {
2320+
postcondition {
2321+
condition = length(one(var.input)) == 1
2322+
error_message = "You can't do that."
2323+
}
2324+
}
2325+
}
2326+
`,
2327+
})
2328+
2329+
ctx := testContext2(t, &ContextOpts{
2330+
Providers: map[addrs.Provider]providers.Factory{
2331+
addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p),
2332+
},
2333+
})
2334+
2335+
diags := ctx.Validate(m)
2336+
if !diags.HasErrors() {
2337+
t.Fatalf("succeeded; want error")
2338+
}
2339+
if got, want := diags.Err().Error(), "Invalid function argument"; !strings.Contains(got, want) {
2340+
t.Errorf("unexpected error.\ngot: %s\nshould contain: %q", got, want)
2341+
}
2342+
}
2343+
2344+
func TestContext2Validate_postcondition_badErrorMessage(t *testing.T) {
2345+
p := testProvider("aws")
2346+
p.GetProviderSchemaResponse = getProviderSchemaResponseFromProviderSchema(&ProviderSchema{
2347+
ResourceTypes: map[string]*configschema.Block{
2348+
"aws_instance": {
2349+
Attributes: map[string]*configschema.Attribute{
2350+
"foo": {Type: cty.String, Optional: true},
2351+
},
2352+
},
2353+
},
2354+
})
2355+
m := testModuleInline(t, map[string]string{
2356+
"main.tf": `
2357+
terraform {
2358+
experiments = [preconditions_postconditions]
2359+
}
2360+
2361+
resource "aws_instance" "test" {
2362+
foo = "foo"
2363+
2364+
lifecycle {
2365+
postcondition {
2366+
condition = self.foo != "foo"
2367+
error_message = "This is a bad use of a function: ${one("foo")}."
2368+
}
2369+
}
2370+
}
2371+
`,
2372+
})
2373+
2374+
ctx := testContext2(t, &ContextOpts{
2375+
Providers: map[addrs.Provider]providers.Factory{
2376+
addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p),
2377+
},
2378+
})
2379+
2380+
diags := ctx.Validate(m)
2381+
if !diags.HasErrors() {
2382+
t.Fatalf("succeeded; want error")
2383+
}
2384+
if got, want := diags.Err().Error(), "Invalid function argument"; !strings.Contains(got, want) {
2385+
t.Errorf("unexpected error.\ngot: %s\nshould contain: %q", got, want)
2386+
}
2387+
}

internal/terraform/node_resource_validate.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,18 @@ func (n *NodeValidatableResource) Path() addrs.ModuleInstance {
4141
func (n *NodeValidatableResource) Execute(ctx EvalContext, op walkOperation) (diags tfdiags.Diagnostics) {
4242
diags = diags.Append(n.validateResource(ctx))
4343

44+
var self addrs.Referenceable
45+
switch {
46+
case n.Config.Count != nil:
47+
self = n.Addr.Resource.Instance(addrs.IntKey(0))
48+
case n.Config.ForEach != nil:
49+
self = n.Addr.Resource.Instance(addrs.StringKey(""))
50+
default:
51+
self = n.Addr.Resource.Instance(addrs.NoKey)
52+
}
53+
diags = diags.Append(validateCheckRules(ctx, n.Config.Preconditions, nil))
54+
diags = diags.Append(validateCheckRules(ctx, n.Config.Postconditions, self))
55+
4456
if managed := n.Config.Managed; managed != nil {
4557
hasCount := n.Config.Count != nil
4658
hasForEach := n.Config.ForEach != nil
@@ -466,6 +478,20 @@ func (n *NodeValidatableResource) validateResource(ctx EvalContext) tfdiags.Diag
466478
return diags
467479
}
468480

481+
func validateCheckRules(ctx EvalContext, crs []*configs.CheckRule, self addrs.Referenceable) tfdiags.Diagnostics {
482+
var diags tfdiags.Diagnostics
483+
484+
for _, cr := range crs {
485+
_, conditionDiags := ctx.EvaluateExpr(cr.Condition, cty.Bool, self)
486+
diags = diags.Append(conditionDiags)
487+
488+
_, errorMessageDiags := ctx.EvaluateExpr(cr.ErrorMessage, cty.String, self)
489+
diags = diags.Append(errorMessageDiags)
490+
}
491+
492+
return diags
493+
}
494+
469495
func validateCount(ctx EvalContext, expr hcl.Expression) (diags tfdiags.Diagnostics) {
470496
val, countDiags := evaluateCountExpressionValue(expr, ctx)
471497
// If the value isn't known then that's the best we can do for now, but

0 commit comments

Comments
 (0)