diff --git a/internal/builtin/providers/terraform/provider.go b/internal/builtin/providers/terraform/provider.go index 10292f4abb02..8a724087aec2 100644 --- a/internal/builtin/providers/terraform/provider.go +++ b/internal/builtin/providers/terraform/provider.go @@ -8,13 +8,7 @@ import ( ) // Provider is an implementation of providers.Interface -type Provider struct { - // Provider is the schema for the provider itself. - Schema providers.Schema - - // DataSources maps the data source name to that data source's schema. - DataSources map[string]providers.Schema -} +type Provider struct{} // NewProvider returns a new terraform provider func NewProvider() providers.Interface { @@ -27,6 +21,9 @@ func (p *Provider) GetProviderSchema() providers.GetProviderSchemaResponse { DataSources: map[string]providers.Schema{ "terraform_remote_state": dataSourceRemoteStateGetSchema(), }, + ResourceTypes: map[string]providers.Schema{ + "terraform_data": dataStoreResourceSchema(), + }, } } @@ -99,26 +96,26 @@ func (p *Provider) Stop() error { // instance state whose schema version is less than the one reported by the // currently-used version of the corresponding provider, and the upgraded // result is used for any further processing. -func (p *Provider) UpgradeResourceState(providers.UpgradeResourceStateRequest) providers.UpgradeResourceStateResponse { - panic("unimplemented - terraform_remote_state has no resources") +func (p *Provider) UpgradeResourceState(req providers.UpgradeResourceStateRequest) providers.UpgradeResourceStateResponse { + return upgradeDataStoreResourceState(req) } // ReadResource refreshes a resource and returns its current state. -func (p *Provider) ReadResource(providers.ReadResourceRequest) providers.ReadResourceResponse { - panic("unimplemented - terraform_remote_state has no resources") +func (p *Provider) ReadResource(req providers.ReadResourceRequest) providers.ReadResourceResponse { + return readDataStoreResourceState(req) } // PlanResourceChange takes the current state and proposed state of a // resource, and returns the planned final state. -func (p *Provider) PlanResourceChange(providers.PlanResourceChangeRequest) providers.PlanResourceChangeResponse { - panic("unimplemented - terraform_remote_state has no resources") +func (p *Provider) PlanResourceChange(req providers.PlanResourceChangeRequest) providers.PlanResourceChangeResponse { + return planDataStoreResourceChange(req) } // ApplyResourceChange takes the planned state for a resource, which may // yet contain unknown computed values, and applies the changes returning // the final state. -func (p *Provider) ApplyResourceChange(providers.ApplyResourceChangeRequest) providers.ApplyResourceChangeResponse { - panic("unimplemented - terraform_remote_state has no resources") +func (p *Provider) ApplyResourceChange(req providers.ApplyResourceChangeRequest) providers.ApplyResourceChangeResponse { + return applyDataStoreResourceChange(req) } // ImportResourceState requests that the given resource be imported. @@ -127,11 +124,8 @@ func (p *Provider) ImportResourceState(providers.ImportResourceStateRequest) pro } // ValidateResourceConfig is used to to validate the resource configuration values. -func (p *Provider) ValidateResourceConfig(providers.ValidateResourceConfigRequest) providers.ValidateResourceConfigResponse { - // At this moment there is nothing to configure for the terraform provider, - // so we will happily return without taking any action - var res providers.ValidateResourceConfigResponse - return res +func (p *Provider) ValidateResourceConfig(req providers.ValidateResourceConfigRequest) providers.ValidateResourceConfigResponse { + return validateDataStoreResourceConfig(req) } // Close is a noop for this provider, since it's run in-process. diff --git a/internal/builtin/providers/terraform/resource_data.go b/internal/builtin/providers/terraform/resource_data.go new file mode 100644 index 000000000000..23ae91fc4a34 --- /dev/null +++ b/internal/builtin/providers/terraform/resource_data.go @@ -0,0 +1,146 @@ +package terraform + +import ( + "fmt" + + "github.com/hashicorp/go-uuid" + "github.com/hashicorp/terraform/internal/configs/configschema" + "github.com/hashicorp/terraform/internal/providers" + "github.com/hashicorp/terraform/internal/tfdiags" + "github.com/zclconf/go-cty/cty" + ctyjson "github.com/zclconf/go-cty/cty/json" +) + +func dataStoreResourceSchema() providers.Schema { + return providers.Schema{ + Block: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "input": {Type: cty.DynamicPseudoType, Optional: true}, + "output": {Type: cty.DynamicPseudoType, Computed: true}, + "triggers_replace": {Type: cty.DynamicPseudoType, Optional: true}, + "id": {Type: cty.String, Computed: true}, + }, + }, + } +} + +func validateDataStoreResourceConfig(req providers.ValidateResourceConfigRequest) (resp providers.ValidateResourceConfigResponse) { + if req.Config.IsNull() { + return resp + } + + // Core does not currently validate computed values are not set in the + // configuration. + for _, attr := range []string{"id", "output"} { + if !req.Config.GetAttr(attr).IsNull() { + resp.Diagnostics = resp.Diagnostics.Append(fmt.Errorf(`%q attribute is read-only`, attr)) + } + } + return resp +} + +func upgradeDataStoreResourceState(req providers.UpgradeResourceStateRequest) (resp providers.UpgradeResourceStateResponse) { + ty := dataStoreResourceSchema().Block.ImpliedType() + val, err := ctyjson.Unmarshal(req.RawStateJSON, ty) + if err != nil { + resp.Diagnostics = resp.Diagnostics.Append(err) + return resp + } + + resp.UpgradedState = val + return resp +} + +func readDataStoreResourceState(req providers.ReadResourceRequest) (resp providers.ReadResourceResponse) { + resp.NewState = req.PriorState + return resp +} + +func planDataStoreResourceChange(req providers.PlanResourceChangeRequest) (resp providers.PlanResourceChangeResponse) { + if req.ProposedNewState.IsNull() { + // destroy op + resp.PlannedState = req.ProposedNewState + return resp + } + + planned := req.ProposedNewState.AsValueMap() + + input := req.ProposedNewState.GetAttr("input") + trigger := req.ProposedNewState.GetAttr("triggers_replace") + + switch { + case req.PriorState.IsNull(): + // Create + // Set the id value to unknown. + planned["id"] = cty.UnknownVal(cty.String) + + // Only compute a new output if input has a non-null value. + if !input.IsNull() { + planned["output"] = cty.UnknownVal(input.Type()) + } + + resp.PlannedState = cty.ObjectVal(planned) + return resp + + case !req.PriorState.GetAttr("triggers_replace").RawEquals(trigger): + // trigger changed, so we need to replace the entire instance + resp.RequiresReplace = append(resp.RequiresReplace, cty.GetAttrPath("triggers_replace")) + planned["id"] = cty.UnknownVal(cty.String) + + // We need to check the input for the replacement instance to compute a + // new output. + if input.IsNull() { + planned["output"] = cty.NullVal(cty.DynamicPseudoType) + } else { + planned["output"] = cty.UnknownVal(input.Type()) + } + + case !req.PriorState.GetAttr("input").RawEquals(input): + // only input changed, so we only need to re-compute output + planned["output"] = cty.UnknownVal(input.Type()) + } + + resp.PlannedState = cty.ObjectVal(planned) + return resp +} + +var testUUIDHook func() string + +func applyDataStoreResourceChange(req providers.ApplyResourceChangeRequest) (resp providers.ApplyResourceChangeResponse) { + if req.PlannedState.IsNull() { + resp.NewState = req.PlannedState + return resp + } + + newState := req.PlannedState.AsValueMap() + + if !req.PlannedState.GetAttr("output").IsKnown() { + newState["output"] = req.PlannedState.GetAttr("input") + } + + if !req.PlannedState.GetAttr("id").IsKnown() { + idString, err := uuid.GenerateUUID() + // Terraform would probably never get this far without a good random + // source, but catch the error anyway. + if err != nil { + diag := tfdiags.AttributeValue( + tfdiags.Error, + "Error generating id", + err.Error(), + cty.GetAttrPath("id"), + ) + + resp.Diagnostics = resp.Diagnostics.Append(diag) + } + + if testUUIDHook != nil { + idString = testUUIDHook() + } + + newState["id"] = cty.StringVal(idString) + } + + resp.NewState = cty.ObjectVal(newState) + + return resp +} diff --git a/internal/builtin/providers/terraform/resource_data_test.go b/internal/builtin/providers/terraform/resource_data_test.go new file mode 100644 index 000000000000..6b1c3a30e119 --- /dev/null +++ b/internal/builtin/providers/terraform/resource_data_test.go @@ -0,0 +1,366 @@ +package terraform + +import ( + "strings" + "testing" + + "github.com/hashicorp/terraform/internal/providers" + "github.com/zclconf/go-cty/cty" + ctyjson "github.com/zclconf/go-cty/cty/json" +) + +func TestManagedDataValidate(t *testing.T) { + cfg := map[string]cty.Value{ + "input": cty.NullVal(cty.DynamicPseudoType), + "output": cty.NullVal(cty.DynamicPseudoType), + "triggers_replace": cty.NullVal(cty.DynamicPseudoType), + "id": cty.NullVal(cty.String), + } + + // empty + req := providers.ValidateResourceConfigRequest{ + TypeName: "terraform_data", + Config: cty.ObjectVal(cfg), + } + + resp := validateDataStoreResourceConfig(req) + if resp.Diagnostics.HasErrors() { + t.Error("empty config error:", resp.Diagnostics.ErrWithWarnings()) + } + + // invalid computed values + cfg["output"] = cty.StringVal("oops") + req.Config = cty.ObjectVal(cfg) + + resp = validateDataStoreResourceConfig(req) + if !resp.Diagnostics.HasErrors() { + t.Error("expected error") + } + + msg := resp.Diagnostics.Err().Error() + if !strings.Contains(msg, "attribute is read-only") { + t.Error("unexpected error", msg) + } +} + +func TestManagedDataUpgradeState(t *testing.T) { + schema := dataStoreResourceSchema() + ty := schema.Block.ImpliedType() + + state := cty.ObjectVal(map[string]cty.Value{ + "input": cty.StringVal("input"), + "output": cty.StringVal("input"), + "triggers_replace": cty.ListVal([]cty.Value{ + cty.StringVal("a"), cty.StringVal("b"), + }), + "id": cty.StringVal("not-quite-unique"), + }) + + jsState, err := ctyjson.Marshal(state, ty) + if err != nil { + t.Fatal(err) + } + + // empty + req := providers.UpgradeResourceStateRequest{ + TypeName: "terraform_data", + RawStateJSON: jsState, + } + + resp := upgradeDataStoreResourceState(req) + if resp.Diagnostics.HasErrors() { + t.Error("upgrade state error:", resp.Diagnostics.ErrWithWarnings()) + } + + if !resp.UpgradedState.RawEquals(state) { + t.Errorf("prior state was:\n%#v\nupgraded state is:\n%#v\n", state, resp.UpgradedState) + } +} + +func TestManagedDataRead(t *testing.T) { + req := providers.ReadResourceRequest{ + TypeName: "terraform_data", + PriorState: cty.ObjectVal(map[string]cty.Value{ + "input": cty.StringVal("input"), + "output": cty.StringVal("input"), + "triggers_replace": cty.ListVal([]cty.Value{ + cty.StringVal("a"), cty.StringVal("b"), + }), + "id": cty.StringVal("not-quite-unique"), + }), + } + + resp := readDataStoreResourceState(req) + if resp.Diagnostics.HasErrors() { + t.Fatal("unexpected error", resp.Diagnostics.ErrWithWarnings()) + } + + if !resp.NewState.RawEquals(req.PriorState) { + t.Errorf("prior state was:\n%#v\nnew state is:\n%#v\n", req.PriorState, resp.NewState) + } +} + +func TestManagedDataPlan(t *testing.T) { + schema := dataStoreResourceSchema().Block + ty := schema.ImpliedType() + + for name, tc := range map[string]struct { + prior cty.Value + proposed cty.Value + planned cty.Value + }{ + "create": { + prior: cty.NullVal(ty), + proposed: cty.ObjectVal(map[string]cty.Value{ + "input": cty.NullVal(cty.DynamicPseudoType), + "output": cty.NullVal(cty.DynamicPseudoType), + "triggers_replace": cty.NullVal(cty.DynamicPseudoType), + "id": cty.NullVal(cty.String), + }), + planned: cty.ObjectVal(map[string]cty.Value{ + "input": cty.NullVal(cty.DynamicPseudoType), + "output": cty.NullVal(cty.DynamicPseudoType), + "triggers_replace": cty.NullVal(cty.DynamicPseudoType), + "id": cty.UnknownVal(cty.String), + }), + }, + + "create-output": { + prior: cty.NullVal(ty), + proposed: cty.ObjectVal(map[string]cty.Value{ + "input": cty.StringVal("input"), + "output": cty.NullVal(cty.DynamicPseudoType), + "triggers_replace": cty.NullVal(cty.DynamicPseudoType), + "id": cty.NullVal(cty.String), + }), + planned: cty.ObjectVal(map[string]cty.Value{ + "input": cty.StringVal("input"), + "output": cty.UnknownVal(cty.String), + "triggers_replace": cty.NullVal(cty.DynamicPseudoType), + "id": cty.UnknownVal(cty.String), + }), + }, + + "update-input": { + prior: cty.ObjectVal(map[string]cty.Value{ + "input": cty.StringVal("input"), + "output": cty.StringVal("input"), + "triggers_replace": cty.NullVal(cty.DynamicPseudoType), + "id": cty.StringVal("not-quite-unique"), + }), + proposed: cty.ObjectVal(map[string]cty.Value{ + "input": cty.UnknownVal(cty.List(cty.String)), + "output": cty.StringVal("input"), + "triggers_replace": cty.NullVal(cty.DynamicPseudoType), + "id": cty.StringVal("not-quite-unique"), + }), + planned: cty.ObjectVal(map[string]cty.Value{ + "input": cty.UnknownVal(cty.List(cty.String)), + "output": cty.UnknownVal(cty.List(cty.String)), + "triggers_replace": cty.NullVal(cty.DynamicPseudoType), + "id": cty.StringVal("not-quite-unique"), + }), + }, + + "update-trigger": { + prior: cty.ObjectVal(map[string]cty.Value{ + "input": cty.StringVal("input"), + "output": cty.StringVal("input"), + "triggers_replace": cty.NullVal(cty.DynamicPseudoType), + "id": cty.StringVal("not-quite-unique"), + }), + proposed: cty.ObjectVal(map[string]cty.Value{ + "input": cty.StringVal("input"), + "output": cty.StringVal("input"), + "triggers_replace": cty.StringVal("new-value"), + "id": cty.StringVal("not-quite-unique"), + }), + planned: cty.ObjectVal(map[string]cty.Value{ + "input": cty.StringVal("input"), + "output": cty.UnknownVal(cty.String), + "triggers_replace": cty.StringVal("new-value"), + "id": cty.UnknownVal(cty.String), + }), + }, + + "update-input-trigger": { + prior: cty.ObjectVal(map[string]cty.Value{ + "input": cty.StringVal("input"), + "output": cty.StringVal("input"), + "triggers_replace": cty.MapVal(map[string]cty.Value{ + "key": cty.StringVal("value"), + }), + "id": cty.StringVal("not-quite-unique"), + }), + proposed: cty.ObjectVal(map[string]cty.Value{ + "input": cty.ListVal([]cty.Value{cty.StringVal("new-input")}), + "output": cty.StringVal("input"), + "triggers_replace": cty.MapVal(map[string]cty.Value{ + "key": cty.StringVal("new value"), + }), + "id": cty.StringVal("not-quite-unique"), + }), + planned: cty.ObjectVal(map[string]cty.Value{ + "input": cty.ListVal([]cty.Value{cty.StringVal("new-input")}), + "output": cty.UnknownVal(cty.List(cty.String)), + "triggers_replace": cty.MapVal(map[string]cty.Value{ + "key": cty.StringVal("new value"), + }), + "id": cty.UnknownVal(cty.String), + }), + }, + } { + t.Run("plan-"+name, func(t *testing.T) { + req := providers.PlanResourceChangeRequest{ + TypeName: "terraform_data", + PriorState: tc.prior, + ProposedNewState: tc.proposed, + } + + resp := planDataStoreResourceChange(req) + if resp.Diagnostics.HasErrors() { + t.Fatal(resp.Diagnostics.ErrWithWarnings()) + } + + if !resp.PlannedState.RawEquals(tc.planned) { + t.Errorf("expected:\n%#v\ngot:\n%#v\n", tc.planned, resp.PlannedState) + } + }) + } +} + +func TestManagedDataApply(t *testing.T) { + testUUIDHook = func() string { + return "not-quite-unique" + } + defer func() { + testUUIDHook = nil + }() + + schema := dataStoreResourceSchema().Block + ty := schema.ImpliedType() + + for name, tc := range map[string]struct { + prior cty.Value + planned cty.Value + state cty.Value + }{ + "create": { + prior: cty.NullVal(ty), + planned: cty.ObjectVal(map[string]cty.Value{ + "input": cty.NullVal(cty.DynamicPseudoType), + "output": cty.NullVal(cty.DynamicPseudoType), + "triggers_replace": cty.NullVal(cty.DynamicPseudoType), + "id": cty.UnknownVal(cty.String), + }), + state: cty.ObjectVal(map[string]cty.Value{ + "input": cty.NullVal(cty.DynamicPseudoType), + "output": cty.NullVal(cty.DynamicPseudoType), + "triggers_replace": cty.NullVal(cty.DynamicPseudoType), + "id": cty.StringVal("not-quite-unique"), + }), + }, + + "create-output": { + prior: cty.NullVal(ty), + planned: cty.ObjectVal(map[string]cty.Value{ + "input": cty.StringVal("input"), + "output": cty.UnknownVal(cty.String), + "triggers_replace": cty.NullVal(cty.DynamicPseudoType), + "id": cty.UnknownVal(cty.String), + }), + state: cty.ObjectVal(map[string]cty.Value{ + "input": cty.StringVal("input"), + "output": cty.StringVal("input"), + "triggers_replace": cty.NullVal(cty.DynamicPseudoType), + "id": cty.StringVal("not-quite-unique"), + }), + }, + + "update-input": { + prior: cty.ObjectVal(map[string]cty.Value{ + "input": cty.StringVal("input"), + "output": cty.StringVal("input"), + "triggers_replace": cty.NullVal(cty.DynamicPseudoType), + "id": cty.StringVal("not-quite-unique"), + }), + planned: cty.ObjectVal(map[string]cty.Value{ + "input": cty.ListVal([]cty.Value{cty.StringVal("new-input")}), + "output": cty.UnknownVal(cty.List(cty.String)), + "triggers_replace": cty.NullVal(cty.DynamicPseudoType), + "id": cty.StringVal("not-quite-unique"), + }), + state: cty.ObjectVal(map[string]cty.Value{ + "input": cty.ListVal([]cty.Value{cty.StringVal("new-input")}), + "output": cty.ListVal([]cty.Value{cty.StringVal("new-input")}), + "triggers_replace": cty.NullVal(cty.DynamicPseudoType), + "id": cty.StringVal("not-quite-unique"), + }), + }, + + "update-trigger": { + prior: cty.ObjectVal(map[string]cty.Value{ + "input": cty.StringVal("input"), + "output": cty.StringVal("input"), + "triggers_replace": cty.NullVal(cty.DynamicPseudoType), + "id": cty.StringVal("not-quite-unique"), + }), + planned: cty.ObjectVal(map[string]cty.Value{ + "input": cty.StringVal("input"), + "output": cty.UnknownVal(cty.String), + "triggers_replace": cty.StringVal("new-value"), + "id": cty.UnknownVal(cty.String), + }), + state: cty.ObjectVal(map[string]cty.Value{ + "input": cty.StringVal("input"), + "output": cty.StringVal("input"), + "triggers_replace": cty.StringVal("new-value"), + "id": cty.StringVal("not-quite-unique"), + }), + }, + + "update-input-trigger": { + prior: cty.ObjectVal(map[string]cty.Value{ + "input": cty.StringVal("input"), + "output": cty.StringVal("input"), + "triggers_replace": cty.MapVal(map[string]cty.Value{ + "key": cty.StringVal("value"), + }), + "id": cty.StringVal("not-quite-unique"), + }), + planned: cty.ObjectVal(map[string]cty.Value{ + "input": cty.ListVal([]cty.Value{cty.StringVal("new-input")}), + "output": cty.UnknownVal(cty.List(cty.String)), + "triggers_replace": cty.MapVal(map[string]cty.Value{ + "key": cty.StringVal("new value"), + }), + "id": cty.UnknownVal(cty.String), + }), + state: cty.ObjectVal(map[string]cty.Value{ + "input": cty.ListVal([]cty.Value{cty.StringVal("new-input")}), + "output": cty.ListVal([]cty.Value{cty.StringVal("new-input")}), + "triggers_replace": cty.MapVal(map[string]cty.Value{ + "key": cty.StringVal("new value"), + }), + "id": cty.StringVal("not-quite-unique"), + }), + }, + } { + t.Run("apply-"+name, func(t *testing.T) { + req := providers.ApplyResourceChangeRequest{ + TypeName: "terraform_data", + PriorState: tc.prior, + PlannedState: tc.planned, + } + + resp := applyDataStoreResourceChange(req) + if resp.Diagnostics.HasErrors() { + t.Fatal(resp.Diagnostics.ErrWithWarnings()) + } + + if !resp.NewState.RawEquals(tc.state) { + t.Errorf("expected:\n%#v\ngot:\n%#v\n", tc.state, resp.NewState) + } + }) + } +} diff --git a/internal/command/e2etest/terraform_test.go b/internal/command/e2etest/terraform_test.go new file mode 100644 index 000000000000..a6e706c3bb01 --- /dev/null +++ b/internal/command/e2etest/terraform_test.go @@ -0,0 +1,55 @@ +package e2etest + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/e2e" +) + +func TestTerraformProviderData(t *testing.T) { + + fixturePath := filepath.Join("testdata", "terraform-managed-data") + tf := e2e.NewBinary(t, terraformBin, fixturePath) + + _, stderr, err := tf.Run("init", "-input=false") + if err != nil { + t.Fatalf("unexpected init error: %s\nstderr:\n%s", err, stderr) + } + + stdout, stderr, err := tf.Run("plan", "-out=tfplan", "-input=false") + if err != nil { + t.Fatalf("unexpected plan error: %s\nstderr:\n%s", err, stderr) + } + + if !strings.Contains(stdout, "4 to add, 0 to change, 0 to destroy") { + t.Errorf("incorrect plan tally; want 4 to add:\n%s", stdout) + } + + stdout, stderr, err = tf.Run("apply", "-input=false", "tfplan") + if err != nil { + t.Fatalf("unexpected apply error: %s\nstderr:\n%s", err, stderr) + } + + if !strings.Contains(stdout, "Resources: 4 added, 0 changed, 0 destroyed") { + t.Errorf("incorrect apply tally; want 4 added:\n%s", stdout) + } + + state, err := tf.LocalState() + if err != nil { + t.Fatalf("failed to read state file: %s", err) + } + + // we'll check the final output to validate the resources + d := state.Module(addrs.RootModuleInstance).OutputValues["d"].Value + input := d.GetAttr("input") + output := d.GetAttr("output") + if input.IsNull() { + t.Fatal("missing input from resource d") + } + if !input.RawEquals(output) { + t.Fatalf("input %#v does not equal output %#v\n", input, output) + } +} diff --git a/internal/command/e2etest/testdata/terraform-managed-data/main.tf b/internal/command/e2etest/testdata/terraform-managed-data/main.tf new file mode 100644 index 000000000000..271888e6a10a --- /dev/null +++ b/internal/command/e2etest/testdata/terraform-managed-data/main.tf @@ -0,0 +1,18 @@ +resource "terraform_data" "a" { +} + +resource "terraform_data" "b" { + input = terraform_data.a.id +} + +resource "terraform_data" "c" { + triggers_replace = terraform_data.b +} + +resource "terraform_data" "d" { + input = [ terraform_data.b, terraform_data.c ] +} + +output "d" { + value = terraform_data.d +}