new terraform_data managed resource

Replace and enhance the `null_resource` functionality with a new
`terraform_data` managed resource.
This commit is contained in:
James Bardin 2022-07-07 16:40:15 -04:00
parent ec6451a82a
commit 3b73ed3348
3 changed files with 526 additions and 20 deletions

View File

@ -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.

View File

@ -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},
"trigger": {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("trigger")
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("trigger").RawEquals(trigger):
// trigger changed, so we need to replace the entire instance
resp.RequiresReplace = append(resp.RequiresReplace, cty.GetAttrPath("trigger"))
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
}

View File

@ -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),
"trigger": 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"),
"trigger": 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"),
"trigger": 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),
"trigger": 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),
"trigger": 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),
"trigger": 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),
"trigger": 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"),
"trigger": 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"),
"trigger": 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)),
"trigger": 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"),
"trigger": 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"),
"trigger": 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),
"trigger": 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"),
"trigger": 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"),
"trigger": 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)),
"trigger": 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),
"trigger": 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),
"trigger": 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),
"trigger": cty.NullVal(cty.DynamicPseudoType),
"id": cty.UnknownVal(cty.String),
}),
state: cty.ObjectVal(map[string]cty.Value{
"input": cty.StringVal("input"),
"output": cty.StringVal("input"),
"trigger": 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"),
"trigger": 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)),
"trigger": 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")}),
"trigger": 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"),
"trigger": 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),
"trigger": cty.StringVal("new-value"),
"id": cty.UnknownVal(cty.String),
}),
state: cty.ObjectVal(map[string]cty.Value{
"input": cty.StringVal("input"),
"output": cty.StringVal("input"),
"trigger": 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"),
"trigger": 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)),
"trigger": 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")}),
"trigger": 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)
}
})
}
}