diff --git a/internal/terraform/context_apply2_test.go b/internal/terraform/context_apply2_test.go index 512f3dab3..38b9d26f6 100644 --- a/internal/terraform/context_apply2_test.go +++ b/internal/terraform/context_apply2_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/lang/marks" @@ -736,3 +737,182 @@ resource "test_object" "b" { t.Fatal("expected cycle error from apply") } } + +func TestContext2Apply_resourcePostcondition(t *testing.T) { + m := testModuleInline(t, map[string]string{ + "main.tf": ` +terraform { + experiments = [preconditions_postconditions] +} + +variable "boop" { + type = string +} + +resource "test_resource" "a" { + value = var.boop +} + +resource "test_resource" "b" { + value = test_resource.a.output + lifecycle { + postcondition { + condition = self.output != "" + error_message = "Output must not be blank." + } + } +} + +resource "test_resource" "c" { + value = test_resource.b.output +} +`, + }) + + p := testProvider("test") + p.GetProviderSchemaResponse = getProviderSchemaResponseFromProviderSchema(&ProviderSchema{ + ResourceTypes: map[string]*configschema.Block{ + "test_resource": { + Attributes: map[string]*configschema.Attribute{ + "value": { + Type: cty.String, + Required: true, + }, + "output": { + Type: cty.String, + Computed: true, + }, + }, + }, + }, + }) + p.PlanResourceChangeFn = func(req providers.PlanResourceChangeRequest) (resp providers.PlanResourceChangeResponse) { + m := req.ProposedNewState.AsValueMap() + m["output"] = cty.UnknownVal(cty.String) + + resp.PlannedState = cty.ObjectVal(m) + resp.LegacyTypeSystem = true + return resp + } + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): testProviderFuncFixed(p), + }, + }) + + t.Run("condition pass", func(t *testing.T) { + plan, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ + Mode: plans.NormalMode, + SetVariables: InputValues{ + "boop": &InputValue{ + Value: cty.StringVal("boop"), + SourceType: ValueFromCLIArg, + }, + }, + }) + assertNoErrors(t, diags) + if len(plan.Changes.Resources) != 3 { + t.Fatalf("unexpected plan changes: %#v", plan.Changes) + } + + p.ApplyResourceChangeFn = func(req providers.ApplyResourceChangeRequest) (resp providers.ApplyResourceChangeResponse) { + m := req.PlannedState.AsValueMap() + m["output"] = cty.StringVal(fmt.Sprintf("new-%s", m["value"].AsString())) + + resp.NewState = cty.ObjectVal(m) + return resp + } + state, diags := ctx.Apply(plan, m) + assertNoErrors(t, diags) + + wantResourceAttrs := map[string]struct{ value, output string }{ + "a": {"boop", "new-boop"}, + "b": {"new-boop", "new-new-boop"}, + "c": {"new-new-boop", "new-new-new-boop"}, + } + for name, attrs := range wantResourceAttrs { + addr := mustResourceInstanceAddr(fmt.Sprintf("test_resource.%s", name)) + r := state.ResourceInstance(addr) + rd, err := r.Current.Decode(cty.Object(map[string]cty.Type{ + "value": cty.String, + "output": cty.String, + })) + if err != nil { + t.Fatalf("error decoding test_resource.a: %s", err) + } + want := cty.ObjectVal(map[string]cty.Value{ + "value": cty.StringVal(attrs.value), + "output": cty.StringVal(attrs.output), + }) + if !cmp.Equal(want, rd.Value, valueComparer) { + t.Errorf("wrong attrs for %s\n%s", addr, cmp.Diff(want, rd.Value, valueComparer)) + } + } + }) + t.Run("condition fail", func(t *testing.T) { + plan, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ + Mode: plans.NormalMode, + SetVariables: InputValues{ + "boop": &InputValue{ + Value: cty.StringVal("boop"), + SourceType: ValueFromCLIArg, + }, + }, + }) + assertNoErrors(t, diags) + if len(plan.Changes.Resources) != 3 { + t.Fatalf("unexpected plan changes: %#v", plan.Changes) + } + + p.ApplyResourceChangeFn = func(req providers.ApplyResourceChangeRequest) (resp providers.ApplyResourceChangeResponse) { + m := req.PlannedState.AsValueMap() + + // For the resource with a constraint, fudge the output to make the + // condition fail. + if value := m["value"].AsString(); value == "new-boop" { + m["output"] = cty.StringVal("") + } else { + m["output"] = cty.StringVal(fmt.Sprintf("new-%s", value)) + } + + resp.NewState = cty.ObjectVal(m) + return resp + } + state, diags := ctx.Apply(plan, m) + if !diags.HasErrors() { + t.Fatal("succeeded; want errors") + } + if got, want := diags.Err().Error(), "Resource postcondition failed: Output must not be blank."; got != want { + t.Fatalf("wrong error:\ngot: %s\nwant: %q", got, want) + } + + // Resources a and b should still be recorded in state + wantResourceAttrs := map[string]struct{ value, output string }{ + "a": {"boop", "new-boop"}, + "b": {"new-boop", ""}, + } + for name, attrs := range wantResourceAttrs { + addr := mustResourceInstanceAddr(fmt.Sprintf("test_resource.%s", name)) + r := state.ResourceInstance(addr) + rd, err := r.Current.Decode(cty.Object(map[string]cty.Type{ + "value": cty.String, + "output": cty.String, + })) + if err != nil { + t.Fatalf("error decoding test_resource.a: %s", err) + } + want := cty.ObjectVal(map[string]cty.Value{ + "value": cty.StringVal(attrs.value), + "output": cty.StringVal(attrs.output), + }) + if !cmp.Equal(want, rd.Value, valueComparer) { + t.Errorf("wrong attrs for %s\n%s", addr, cmp.Diff(want, rd.Value, valueComparer)) + } + } + + // Resource c should not be in state + if state.ResourceInstance(mustResourceInstanceAddr("test_resource.c")) != nil { + t.Error("test_resource.c should not exist in state, but is") + } + }) +} diff --git a/internal/terraform/context_plan2_test.go b/internal/terraform/context_plan2_test.go index d1771b1f3..622c4c803 100644 --- a/internal/terraform/context_plan2_test.go +++ b/internal/terraform/context_plan2_test.go @@ -2178,3 +2178,438 @@ func TestContext2Plan_moduleExpandOrphansResourceInstance(t *testing.T) { } }) } + +func TestContext2Plan_resourcePreconditionPostcondition(t *testing.T) { + m := testModuleInline(t, map[string]string{ + "main.tf": ` +terraform { + experiments = [preconditions_postconditions] +} + +variable "boop" { + type = string +} + +resource "test_resource" "a" { + value = var.boop + lifecycle { + precondition { + condition = var.boop == "boop" + error_message = "Wrong boop." + } + postcondition { + condition = self.output != "" + error_message = "Output must not be blank." + } + } +} + +`, + }) + + p := testProvider("test") + p.GetProviderSchemaResponse = getProviderSchemaResponseFromProviderSchema(&ProviderSchema{ + ResourceTypes: map[string]*configschema.Block{ + "test_resource": { + Attributes: map[string]*configschema.Attribute{ + "value": { + Type: cty.String, + Required: true, + }, + "output": { + Type: cty.String, + Computed: true, + }, + }, + }, + }, + }) + + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): testProviderFuncFixed(p), + }, + }) + + t.Run("conditions pass", func(t *testing.T) { + p.PlanResourceChangeFn = func(req providers.PlanResourceChangeRequest) (resp providers.PlanResourceChangeResponse) { + m := req.ProposedNewState.AsValueMap() + m["output"] = cty.StringVal("bar") + + resp.PlannedState = cty.ObjectVal(m) + resp.LegacyTypeSystem = true + return resp + } + plan, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ + Mode: plans.NormalMode, + SetVariables: InputValues{ + "boop": &InputValue{ + Value: cty.StringVal("boop"), + SourceType: ValueFromCLIArg, + }, + }, + }) + assertNoErrors(t, diags) + for _, res := range plan.Changes.Resources { + switch res.Addr.String() { + case "test_resource.a": + if res.Action != plans.Create { + t.Fatalf("unexpected %s change for %s", res.Action, res.Addr) + } + default: + t.Fatalf("unexpected %s change for %s", res.Action, res.Addr) + } + } + }) + + t.Run("precondition fail", func(t *testing.T) { + _, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ + Mode: plans.NormalMode, + SetVariables: InputValues{ + "boop": &InputValue{ + Value: cty.StringVal("nope"), + SourceType: ValueFromCLIArg, + }, + }, + }) + if !diags.HasErrors() { + t.Fatal("succeeded; want errors") + } + if got, want := diags.Err().Error(), "Resource precondition failed: Wrong boop."; got != want { + t.Fatalf("wrong error:\ngot: %s\nwant: %q", got, want) + } + if p.PlanResourceChangeCalled { + t.Errorf("Provider's PlanResourceChange was called; should'nt've been") + } + }) + + t.Run("postcondition fail", func(t *testing.T) { + p.PlanResourceChangeFn = func(req providers.PlanResourceChangeRequest) (resp providers.PlanResourceChangeResponse) { + m := req.ProposedNewState.AsValueMap() + m["output"] = cty.StringVal("") + + resp.PlannedState = cty.ObjectVal(m) + resp.LegacyTypeSystem = true + return resp + } + _, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ + Mode: plans.NormalMode, + SetVariables: InputValues{ + "boop": &InputValue{ + Value: cty.StringVal("boop"), + SourceType: ValueFromCLIArg, + }, + }, + }) + if !diags.HasErrors() { + t.Fatal("succeeded; want errors") + } + if got, want := diags.Err().Error(), "Resource postcondition failed: Output must not be blank."; got != want { + t.Fatalf("wrong error:\ngot: %s\nwant: %q", got, want) + } + if !p.PlanResourceChangeCalled { + t.Errorf("Provider's PlanResourceChangeCalled wasn't called; should've been") + } + }) +} + +func TestContext2Plan_dataSourcePreconditionPostcondition(t *testing.T) { + m := testModuleInline(t, map[string]string{ + "main.tf": ` +terraform { + experiments = [preconditions_postconditions] +} + +variable "boop" { + type = string +} + +data "test_data_source" "a" { + foo = var.boop + lifecycle { + precondition { + condition = var.boop == "boop" + error_message = "Wrong boop." + } + postcondition { + condition = length(self.results) > 0 + error_message = "Results cannot be empty." + } + } +} + +resource "test_resource" "a" { + value = data.test_data_source.a.results[0] +} +`, + }) + + p := testProvider("test") + p.GetProviderSchemaResponse = getProviderSchemaResponseFromProviderSchema(&ProviderSchema{ + ResourceTypes: map[string]*configschema.Block{ + "test_resource": { + Attributes: map[string]*configschema.Attribute{ + "value": { + Type: cty.String, + Required: true, + }, + }, + }, + }, + DataSources: map[string]*configschema.Block{ + "test_data_source": { + Attributes: map[string]*configschema.Attribute{ + "foo": { + Type: cty.String, + Required: true, + }, + "results": { + Type: cty.List(cty.String), + Computed: true, + }, + }, + }, + }, + }) + + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): testProviderFuncFixed(p), + }, + }) + + t.Run("conditions pass", func(t *testing.T) { + p.ReadDataSourceResponse = &providers.ReadDataSourceResponse{ + State: cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("boop"), + "results": cty.ListVal([]cty.Value{cty.StringVal("boop")}), + }), + } + plan, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ + Mode: plans.NormalMode, + SetVariables: InputValues{ + "boop": &InputValue{ + Value: cty.StringVal("boop"), + SourceType: ValueFromCLIArg, + }, + }, + }) + assertNoErrors(t, diags) + for _, res := range plan.Changes.Resources { + switch res.Addr.String() { + case "test_resource.a": + if res.Action != plans.Create { + t.Fatalf("unexpected %s change for %s", res.Action, res.Addr) + } + case "data.test_data_source.a": + if res.Action != plans.Read { + t.Fatalf("unexpected %s change for %s", res.Action, res.Addr) + } + default: + t.Fatalf("unexpected %s change for %s", res.Action, res.Addr) + } + } + }) + + t.Run("precondition fail", func(t *testing.T) { + _, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ + Mode: plans.NormalMode, + SetVariables: InputValues{ + "boop": &InputValue{ + Value: cty.StringVal("nope"), + SourceType: ValueFromCLIArg, + }, + }, + }) + if !diags.HasErrors() { + t.Fatal("succeeded; want errors") + } + if got, want := diags.Err().Error(), "Resource precondition failed: Wrong boop."; got != want { + t.Fatalf("wrong error:\ngot: %s\nwant: %q", got, want) + } + if p.ReadDataSourceCalled { + t.Errorf("Provider's ReadResource was called; should'nt've been") + } + }) + + t.Run("postcondition fail", func(t *testing.T) { + p.ReadDataSourceResponse = &providers.ReadDataSourceResponse{ + State: cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("boop"), + "results": cty.ListValEmpty(cty.String), + }), + } + _, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ + Mode: plans.NormalMode, + SetVariables: InputValues{ + "boop": &InputValue{ + Value: cty.StringVal("boop"), + SourceType: ValueFromCLIArg, + }, + }, + }) + if !diags.HasErrors() { + t.Fatal("succeeded; want errors") + } + if got, want := diags.Err().Error(), "Resource postcondition failed: Results cannot be empty."; got != want { + t.Fatalf("wrong error:\ngot: %s\nwant: %q", got, want) + } + if !p.ReadDataSourceCalled { + t.Errorf("Provider's ReadDataSource wasn't called; should've been") + } + }) +} + +func TestContext2Plan_outputPrecondition(t *testing.T) { + m := testModuleInline(t, map[string]string{ + "main.tf": ` +terraform { + experiments = [preconditions_postconditions] +} + +variable "boop" { + type = string +} + +output "a" { + value = var.boop + precondition { + condition = var.boop == "boop" + error_message = "Wrong boop." + } +} +`, + }) + + p := testProvider("test") + + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): testProviderFuncFixed(p), + }, + }) + + t.Run("condition pass", func(t *testing.T) { + plan, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ + Mode: plans.NormalMode, + SetVariables: InputValues{ + "boop": &InputValue{ + Value: cty.StringVal("boop"), + SourceType: ValueFromCLIArg, + }, + }, + }) + assertNoErrors(t, diags) + addr := addrs.RootModuleInstance.OutputValue("a") + outputPlan := plan.Changes.OutputValue(addr) + if outputPlan == nil { + t.Fatalf("no plan for %s at all", addr) + } + if got, want := outputPlan.Addr, addr; !got.Equal(want) { + t.Errorf("wrong current address\ngot: %s\nwant: %s", got, want) + } + if got, want := outputPlan.Action, plans.Create; got != want { + t.Errorf("wrong planned action\ngot: %s\nwant: %s", got, want) + } + }) + + t.Run("condition fail", func(t *testing.T) { + _, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ + Mode: plans.NormalMode, + SetVariables: InputValues{ + "boop": &InputValue{ + Value: cty.StringVal("nope"), + SourceType: ValueFromCLIArg, + }, + }, + }) + if !diags.HasErrors() { + t.Fatal("succeeded; want errors") + } + if got, want := diags.Err().Error(), "Module output value precondition failed: Wrong boop."; got != want { + t.Fatalf("wrong error:\ngot: %s\nwant: %q", got, want) + } + }) +} + +func TestContext2Plan_preconditionErrors(t *testing.T) { + testCases := []struct { + condition string + wantSummary string + wantDetail string + }{ + { + "data.test_data_source", + "Invalid reference", + `The "data" object must be followed by two attribute names`, + }, + { + "self.value", + `Invalid "self" reference`, + "only in resource provisioner, connection, and postcondition blocks", + }, + { + "data.foo.bar", + "Reference to undeclared resource", + `A data resource "foo" "bar" has not been declared in the root module`, + }, + { + "test_resource.b.value", + "Invalid condition result", + "Condition expression must return either true or false", + }, + { + "test_resource.c.value", + "Invalid condition result", + "Invalid validation condition result value: a bool is required", + }, + } + + p := testProvider("test") + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): testProviderFuncFixed(p), + }, + }) + + for _, tc := range testCases { + t.Run(tc.condition, func(t *testing.T) { + main := fmt.Sprintf(` + terraform { + experiments = [preconditions_postconditions] + } + + resource "test_resource" "a" { + value = var.boop + lifecycle { + precondition { + condition = %s + error_message = "Not relevant." + } + } + } + + resource "test_resource" "b" { + value = null + } + + resource "test_resource" "c" { + value = "bar" + } + `, tc.condition) + m := testModuleInline(t, map[string]string{"main.tf": main}) + + _, diags := ctx.Plan(m, states.NewState(), DefaultPlanOpts) + if !diags.HasErrors() { + t.Fatal("succeeded; want errors") + } + diag := diags[0] + if got, want := diag.Description().Summary, tc.wantSummary; got != want { + t.Errorf("unexpected summary\n got: %s\nwant: %s", got, want) + } + if got, want := diag.Description().Detail, tc.wantDetail; !strings.Contains(got, want) { + t.Errorf("unexpected summary\ngot: %s\nwant to contain %q", got, want) + } + }) + } +}