diff --git a/helper/plugin/diagnostics.go b/helper/plugin/diagnostics.go new file mode 100644 index 0000000000..b5be91db24 --- /dev/null +++ b/helper/plugin/diagnostics.go @@ -0,0 +1,41 @@ +package plugin + +import ( + "github.com/hashicorp/terraform/plugin/proto" +) + +// diagsFromWarnsErrs converts the warnings and errors return by the lagacy +// provider to diagnostics. +func diagsFromWarnsErrs(warns []string, errs []error) (diags []*proto.Diagnostic) { + for _, w := range warns { + diags = appendDiag(diags, w) + } + + for _, e := range errs { + diags = appendDiag(diags, e) + } + + return diags +} + +// appendDiag appends a new diagnostic from a warning string or an error. This +// panics if d is not a string or error. +func appendDiag(diags []*proto.Diagnostic, d interface{}) []*proto.Diagnostic { + switch d := d.(type) { + case error: + diags = append(diags, &proto.Diagnostic{ + Severity: proto.Diagnostic_ERROR, + Summary: d.Error(), + }) + case string: + diags = append(diags, &proto.Diagnostic{ + Severity: proto.Diagnostic_WARNING, + Summary: d, + }) + case *proto.Diagnostic: + diags = append(diags, d) + case []*proto.Diagnostic: + diags = append(diags, d...) + } + return diags +} diff --git a/helper/plugin/diagnostics_test.go b/helper/plugin/diagnostics_test.go new file mode 100644 index 0000000000..22e50815b2 --- /dev/null +++ b/helper/plugin/diagnostics_test.go @@ -0,0 +1,45 @@ +package plugin + +import ( + "errors" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform/plugin/proto" +) + +func TestDiagnostics(t *testing.T) { + diags := diagsFromWarnsErrs( + []string{ + "warning 1", + "warning 2", + }, + []error{ + errors.New("error 1"), + errors.New("error 2"), + }, + ) + + expected := []*proto.Diagnostic{ + { + Severity: proto.Diagnostic_WARNING, + Summary: "warning 1", + }, + { + Severity: proto.Diagnostic_WARNING, + Summary: "warning 2", + }, + { + Severity: proto.Diagnostic_ERROR, + Summary: "error 1", + }, + { + Severity: proto.Diagnostic_ERROR, + Summary: "error 2", + }, + } + + if !cmp.Equal(expected, diags) { + t.Fatal(cmp.Diff(expected, diags)) + } +} diff --git a/helper/plugin/doc.go b/helper/plugin/doc.go new file mode 100644 index 0000000000..82b5937bfe --- /dev/null +++ b/helper/plugin/doc.go @@ -0,0 +1,6 @@ +// Package plugin contains types and functions to help Terraform plugins +// implement the plugin rpc interface. +// The primary Provider type will be responsible for converting from the grpc +// wire protocol to the types and methods known to the provider +// implementations. +package plugin diff --git a/helper/plugin/grpc_provider.go b/helper/plugin/grpc_provider.go new file mode 100644 index 0000000000..de194843ee --- /dev/null +++ b/helper/plugin/grpc_provider.go @@ -0,0 +1,616 @@ +package plugin + +import ( + "encoding/json" + "errors" + "strconv" + + context "golang.org/x/net/context" + + "github.com/hashicorp/terraform/config/hcl2shim" + "github.com/hashicorp/terraform/configs/configschema" + "github.com/hashicorp/terraform/helper/schema" + "github.com/hashicorp/terraform/plugin/proto" + "github.com/hashicorp/terraform/terraform" + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/msgpack" +) + +// GRPCProviderServer handles the server, or plugin side of the rpc connection. +type GRPCProviderServer struct { + provider *schema.Provider +} + +func (s *GRPCProviderServer) GetSchema(_ context.Context, req *proto.GetProviderSchema_Request) (*proto.GetProviderSchema_Response, error) { + resp := &proto.GetProviderSchema_Response{ + ResourceSchemas: make(map[string]*proto.Schema), + DataSourceSchemas: make(map[string]*proto.Schema), + } + + resp.Provider = &proto.Schema{ + Block: protoSchemaBlock(s.getProviderSchemaBlock()), + } + + for typ, res := range s.provider.ResourcesMap { + resp.ResourceSchemas[typ] = &proto.Schema{ + Version: int64(res.SchemaVersion), + Block: protoSchemaBlock(res.CoreConfigSchema()), + } + } + + for typ, dat := range s.provider.DataSourcesMap { + resp.DataSourceSchemas[typ] = &proto.Schema{ + Version: int64(dat.SchemaVersion), + Block: protoSchemaBlock(dat.CoreConfigSchema()), + } + } + + return resp, nil +} + +func (s *GRPCProviderServer) getProviderSchemaBlock() *configschema.Block { + return schema.InternalMap(s.provider.Schema).CoreConfigSchema() +} + +func (s *GRPCProviderServer) getResourceSchemaBlock(name string) *configschema.Block { + res := s.provider.ResourcesMap[name] + return res.CoreConfigSchema() +} + +func (s *GRPCProviderServer) getDatasourceSchemaBlock(name string) *configschema.Block { + dat := s.provider.DataSourcesMap[name] + return dat.CoreConfigSchema() +} + +func (s *GRPCProviderServer) ValidateProviderConfig(_ context.Context, req *proto.ValidateProviderConfig_Request) (*proto.ValidateProviderConfig_Response, error) { + resp := &proto.ValidateProviderConfig_Response{} + + block := s.getProviderSchemaBlock() + + configVal, err := msgpack.Unmarshal(req.Config.Msgpack, block.ImpliedType()) + if err != nil { + resp.Diagnostics = appendDiag(resp.Diagnostics, err) + return resp, nil + } + + config := terraform.NewResourceConfigShimmed(configVal, block) + + warns, errs := s.provider.Validate(config) + resp.Diagnostics = appendDiag(resp.Diagnostics, diagsFromWarnsErrs(warns, errs)) + + return resp, nil +} + +func (s *GRPCProviderServer) ValidateResourceTypeConfig(_ context.Context, req *proto.ValidateResourceTypeConfig_Request) (*proto.ValidateResourceTypeConfig_Response, error) { + resp := &proto.ValidateResourceTypeConfig_Response{} + + block := s.getResourceSchemaBlock(req.TypeName) + + configVal, err := msgpack.Unmarshal(req.Config.Msgpack, block.ImpliedType()) + if err != nil { + resp.Diagnostics = appendDiag(resp.Diagnostics, err) + return resp, nil + } + + config := terraform.NewResourceConfigShimmed(configVal, block) + + warns, errs := s.provider.ValidateResource(req.TypeName, config) + resp.Diagnostics = appendDiag(resp.Diagnostics, diagsFromWarnsErrs(warns, errs)) + + return resp, nil +} + +func (s *GRPCProviderServer) ValidateDataSourceConfig(_ context.Context, req *proto.ValidateDataSourceConfig_Request) (*proto.ValidateDataSourceConfig_Response, error) { + resp := &proto.ValidateDataSourceConfig_Response{} + + block := s.getDatasourceSchemaBlock(req.TypeName) + + configVal, err := msgpack.Unmarshal(req.Config.Msgpack, block.ImpliedType()) + if err != nil { + resp.Diagnostics = appendDiag(resp.Diagnostics, err) + return resp, nil + } + + config := terraform.NewResourceConfigShimmed(configVal, block) + + warns, errs := s.provider.ValidateResource(req.TypeName, config) + resp.Diagnostics = appendDiag(resp.Diagnostics, diagsFromWarnsErrs(warns, errs)) + + return resp, nil +} + +func (s *GRPCProviderServer) UpgradeResourceState(_ context.Context, req *proto.UpgradeResourceState_Request) (*proto.UpgradeResourceState_Response, error) { + resp := &proto.UpgradeResourceState_Response{} + + res := s.provider.ResourcesMap[req.TypeName] + block := res.CoreConfigSchema() + + version := int(req.Version) + + var jsonMap map[string]interface{} + var err error + + // if there's a JSON state, we need to decode it. + if req.RawState.Json != nil { + err = json.Unmarshal(req.RawState.Json, &jsonMap) + if err != nil { + resp.Diagnostics = appendDiag(resp.Diagnostics, err) + return resp, nil + } + } + + // We first need to upgrade a flatmap state if it exists. + // There should never be both a JSON and Flatmap state in the request. + if req.RawState.Flatmap != nil { + jsonMap, version, err = s.upgradeFlatmapState(version, req.RawState.Flatmap, res) + if err != nil { + resp.Diagnostics = appendDiag(resp.Diagnostics, err) + return resp, nil + } + } + + // complete the upgrade of the JSON states + jsonMap, err = s.upgradeJSONState(version, jsonMap, res) + if err != nil { + resp.Diagnostics = appendDiag(resp.Diagnostics, err) + return resp, nil + } + + // now we need to turn the state into the default json representation, so + // that it can be re-decoded using the actual schema. + val, err := schema.JSONMapToStateValue(jsonMap, block) + if err != nil { + resp.Diagnostics = appendDiag(resp.Diagnostics, err) + return resp, nil + } + + // encode the final state to the expected msgpack format + newStateMP, err := msgpack.Marshal(val, block.ImpliedType()) + if err != nil { + resp.Diagnostics = appendDiag(resp.Diagnostics, err) + return resp, nil + } + + resp.UpgradedState = &proto.DynamicValue{Msgpack: newStateMP} + return resp, nil +} + +// upgradeFlatmapState takes a legacy flatmap state, upgrades it using Migrate +// state if necessary, and converts it to the new JSON state format decoded as a +// map[string]interface{}. +// upgradeFlatmapState returns the json map along with the corresponding schema +// version. +func (s *GRPCProviderServer) upgradeFlatmapState(version int, m map[string]string, res *schema.Resource) (map[string]interface{}, int, error) { + // this will be the version we've upgraded so, defaulting to the given + // version in case no migration was called. + upgradedVersion := version + + // first determine if we need to call the legacy MigrateState func + requiresMigrate := version < res.SchemaVersion + + schemaType := res.CoreConfigSchema().ImpliedType() + + // if there are any StateUpgraders, then we need to only compare + // against the first version there + if len(res.StateUpgraders) > 0 { + requiresMigrate = version < res.StateUpgraders[0].Version + } + + if requiresMigrate { + if res.MigrateState == nil { + return nil, 0, errors.New("cannot upgrade state, missing MigrateState function") + } + + is := &terraform.InstanceState{ + ID: m["id"], + Attributes: m, + Meta: map[string]interface{}{ + "schema_version": strconv.Itoa(version), + }, + } + + is, err := res.MigrateState(version, is, s.provider.Meta()) + if err != nil { + return nil, 0, err + } + + // re-assign the map in case there was a copy made, making sure to keep + // the ID + m := is.Attributes + m["id"] = is.ID + + // if there are further upgraders, then we've only updated that far + if len(res.StateUpgraders) > 0 { + schemaType = res.StateUpgraders[0].Type + upgradedVersion = res.StateUpgraders[0].Version + } + } else { + // the schema version may be newer than the MigrateState functions + // handled and older than the current, but still stored in the flatmap + // form. If that's the case, we need to find the correct schema type to + // convert the state. + for _, upgrader := range res.StateUpgraders { + if upgrader.Version == version { + schemaType = upgrader.Type + break + } + } + } + + // now we know the state is up to the latest version that handled the + // flatmap format state. Now we can upgrade the format and continue from + // there. + newConfigVal, err := hcl2shim.HCL2ValueFromFlatmap(m, schemaType) + if err != nil { + return nil, 0, err + } + + jsonMap, err := schema.StateValueToJSONMap(newConfigVal, schemaType) + return jsonMap, upgradedVersion, err +} + +func (s *GRPCProviderServer) upgradeJSONState(version int, m map[string]interface{}, res *schema.Resource) (map[string]interface{}, error) { + var err error + + for _, upgrader := range res.StateUpgraders { + if version != upgrader.Version { + continue + } + + m, err = upgrader.Upgrade(m, s.provider.Meta()) + if err != nil { + return nil, err + } + version++ + } + + return m, nil +} + +func (s *GRPCProviderServer) Stop(_ context.Context, _ *proto.Stop_Request) (*proto.Stop_Response, error) { + resp := &proto.Stop_Response{} + + err := s.provider.Stop() + if err != nil { + resp.Error = err.Error() + } + + return resp, nil +} + +func (s *GRPCProviderServer) Configure(_ context.Context, req *proto.Configure_Request) (*proto.Configure_Response, error) { + resp := &proto.Configure_Response{} + + block := s.getProviderSchemaBlock() + + configVal, err := msgpack.Unmarshal(req.Config.Msgpack, block.ImpliedType()) + if err != nil { + resp.Diagnostics = appendDiag(resp.Diagnostics, err) + return resp, nil + } + + config := terraform.NewResourceConfigShimmed(configVal, block) + err = s.provider.Configure(config) + resp.Diagnostics = appendDiag(resp.Diagnostics, err) + + return resp, nil +} + +func (s *GRPCProviderServer) ReadResource(_ context.Context, req *proto.ReadResource_Request) (*proto.ReadResource_Response, error) { + resp := &proto.ReadResource_Response{} + + res := s.provider.ResourcesMap[req.TypeName] + block := res.CoreConfigSchema() + + stateVal, err := msgpack.Unmarshal(req.CurrentState.Msgpack, block.ImpliedType()) + if err != nil { + resp.Diagnostics = appendDiag(resp.Diagnostics, err) + return resp, nil + } + + instanceState := schema.InstanceStateFromStateValue(stateVal, res.SchemaVersion) + + newInstanceState, err := res.RefreshWithoutUpgrade(instanceState, s.provider.Meta()) + if err != nil { + resp.Diagnostics = appendDiag(resp.Diagnostics, err) + return resp, nil + } + + // helper/schema should always copy the ID over, but do it again just to be safe + newInstanceState.Attributes["id"] = newInstanceState.ID + + newConfigVal, err := hcl2shim.HCL2ValueFromFlatmap(newInstanceState.Attributes, block.ImpliedType()) + if err != nil { + resp.Diagnostics = appendDiag(resp.Diagnostics, err) + return resp, nil + } + + newConfigMP, err := msgpack.Marshal(newConfigVal, block.ImpliedType()) + if err != nil { + resp.Diagnostics = appendDiag(resp.Diagnostics, err) + return resp, nil + } + + resp.NewState = &proto.DynamicValue{ + Msgpack: newConfigMP, + } + + return resp, nil +} + +func (s *GRPCProviderServer) PlanResourceChange(_ context.Context, req *proto.PlanResourceChange_Request) (*proto.PlanResourceChange_Response, error) { + resp := &proto.PlanResourceChange_Response{} + + res := s.provider.ResourcesMap[req.TypeName] + block := res.CoreConfigSchema() + + priorStateVal, err := msgpack.Unmarshal(req.PriorState.Msgpack, block.ImpliedType()) + if err != nil { + resp.Diagnostics = appendDiag(resp.Diagnostics, err) + return resp, nil + } + + proposedNewStateVal, err := msgpack.Unmarshal(req.ProposedNewState.Msgpack, block.ImpliedType()) + if err != nil { + resp.Diagnostics = appendDiag(resp.Diagnostics, err) + return resp, nil + } + + info := &terraform.InstanceInfo{ + Type: req.TypeName, + } + + priorState := schema.InstanceStateFromStateValue(priorStateVal, res.SchemaVersion) + + // turn the propsed state into a legacy configuration + config := terraform.NewResourceConfigShimmed(proposedNewStateVal, block) + + diff, err := s.provider.Diff(info, priorState, config) + if err != nil { + resp.Diagnostics = appendDiag(resp.Diagnostics, err) + return resp, nil + } + + // now we need to apply the diff to the prior state, so get the planned state + plannedStateVal, err := schema.ApplyDiff(priorStateVal, diff, block) + if err != nil { + resp.Diagnostics = appendDiag(resp.Diagnostics, err) + return resp, nil + } + + plannedMP, err := msgpack.Marshal(plannedStateVal, block.ImpliedType()) + if err != nil { + resp.Diagnostics = appendDiag(resp.Diagnostics, err) + return resp, nil + } + resp.PlannedState.Msgpack = plannedMP + + // the Meta field gets encoded into PlannedPrivate + plannedPrivate, err := json.Marshal(diff.Meta) + if err != nil { + resp.Diagnostics = appendDiag(resp.Diagnostics, err) + return resp, nil + } + resp.PlannedPrivate = plannedPrivate + + // collect the attributes that require instance replacement, and convert + // them to cty.Paths. + var requiresNew []string + for attr, d := range diff.Attributes { + if d.RequiresNew { + requiresNew = append(requiresNew, attr) + } + } + + requiresReplace, err := hcl2shim.RequiresReplace(requiresNew, block.ImpliedType()) + if err != nil { + resp.Diagnostics = appendDiag(resp.Diagnostics, err) + return resp, nil + } + + // convert these to the protocol structures + for _, p := range requiresReplace { + resp.RequiresReplace = append(resp.RequiresReplace, pathToAttributePath(p)) + } + + return resp, nil +} + +func (s *GRPCProviderServer) ApplyResourceChange(_ context.Context, req *proto.ApplyResourceChange_Request) (*proto.ApplyResourceChange_Response, error) { + resp := &proto.ApplyResourceChange_Response{} + + res := s.provider.ResourcesMap[req.TypeName] + block := res.CoreConfigSchema() + + priorStateVal, err := msgpack.Unmarshal(req.PriorState.Msgpack, block.ImpliedType()) + if err != nil { + resp.Diagnostics = appendDiag(resp.Diagnostics, err) + return resp, nil + } + + plannedStateVal, err := msgpack.Unmarshal(req.PlannedState.Msgpack, block.ImpliedType()) + if err != nil { + resp.Diagnostics = appendDiag(resp.Diagnostics, err) + return resp, nil + } + + info := &terraform.InstanceInfo{ + Type: req.TypeName, + } + + priorState := schema.InstanceStateFromStateValue(priorStateVal, res.SchemaVersion) + + var private map[string]interface{} + if err := json.Unmarshal(req.PlannedPrivate, &private); err != nil { + resp.Diagnostics = appendDiag(resp.Diagnostics, err) + return resp, nil + } + + diff, err := schema.DiffFromValues(priorStateVal, plannedStateVal, res) + if err != nil { + resp.Diagnostics = appendDiag(resp.Diagnostics, err) + return resp, nil + } + + newInstanceState, err := s.provider.Apply(info, priorState, diff) + if err != nil { + resp.Diagnostics = appendDiag(resp.Diagnostics, err) + return resp, nil + } + + newStateVal, err := schema.StateValueFromInstanceState(newInstanceState, block.ImpliedType()) + if err != nil { + resp.Diagnostics = appendDiag(resp.Diagnostics, err) + return resp, nil + } + + newStateMP, err := msgpack.Marshal(newStateVal, block.ImpliedType()) + if err != nil { + resp.Diagnostics = appendDiag(resp.Diagnostics, err) + return resp, nil + } + resp.NewState.Msgpack = newStateMP + + meta, err := json.Marshal(newInstanceState.Meta) + if err != nil { + resp.Diagnostics = appendDiag(resp.Diagnostics, err) + return resp, nil + } + resp.Private = meta + + return resp, nil +} + +func (s *GRPCProviderServer) ImportResourceState(_ context.Context, req *proto.ImportResourceState_Request) (*proto.ImportResourceState_Response, error) { + resp := &proto.ImportResourceState_Response{} + + block := s.getResourceSchemaBlock(req.TypeName) + + info := &terraform.InstanceInfo{ + Type: req.TypeName, + } + + newInstanceStates, err := s.provider.ImportState(info, req.Id) + if err != nil { + resp.Diagnostics = appendDiag(resp.Diagnostics, err) + return resp, nil + } + + for _, is := range newInstanceStates { + // copy the ID again just to be sure it wasn't missed + is.Attributes["id"] = is.ID + + newStateVal, err := hcl2shim.HCL2ValueFromFlatmap(is.Attributes, block.ImpliedType()) + if err != nil { + resp.Diagnostics = appendDiag(resp.Diagnostics, err) + return resp, nil + } + + newStateMP, err := msgpack.Marshal(newStateVal, block.ImpliedType()) + if err != nil { + resp.Diagnostics = appendDiag(resp.Diagnostics, err) + return resp, nil + } + + meta, err := json.Marshal(is.Meta) + if err != nil { + resp.Diagnostics = appendDiag(resp.Diagnostics, err) + return resp, nil + } + + // the legacy implementation could only import one type at a time + importedResource := &proto.ImportResourceState_ImportedResource{ + TypeName: req.TypeName, + State: &proto.DynamicValue{ + Msgpack: newStateMP, + }, + Private: meta, + } + + resp.ImportedResources = append(resp.ImportedResources, importedResource) + } + + return resp, nil +} + +func (s *GRPCProviderServer) ReadDataSource(_ context.Context, req *proto.ReadDataSource_Request) (*proto.ReadDataSource_Response, error) { + resp := &proto.ReadDataSource_Response{} + + res := s.provider.DataSourcesMap[req.TypeName] + block := res.CoreConfigSchema() + + configVal, err := msgpack.Unmarshal(req.Config.Msgpack, block.ImpliedType()) + if err != nil { + resp.Diagnostics = appendDiag(resp.Diagnostics, err) + return resp, nil + } + + info := &terraform.InstanceInfo{ + Type: req.TypeName, + } + + config := terraform.NewResourceConfigShimmed(configVal, block) + + // we need to still build the diff separately with the Read method to match + // the old behavior + diff, err := s.provider.ReadDataDiff(info, config) + if err != nil { + resp.Diagnostics = appendDiag(resp.Diagnostics, err) + return resp, nil + } + + // now we can get the new complete data source + newInstanceState, err := s.provider.ReadDataApply(info, diff) + if err != nil { + resp.Diagnostics = appendDiag(resp.Diagnostics, err) + return resp, nil + } + + newStateVal, err := schema.StateValueFromInstanceState(newInstanceState, block.ImpliedType()) + if err != nil { + resp.Diagnostics = appendDiag(resp.Diagnostics, err) + return resp, nil + } + + newStateMP, err := msgpack.Marshal(newStateVal, block.ImpliedType()) + if err != nil { + resp.Diagnostics = appendDiag(resp.Diagnostics, err) + return resp, nil + } + resp.State.Msgpack = newStateMP + return resp, nil +} + +func pathToAttributePath(path cty.Path) *proto.AttributePath { + var steps []*proto.AttributePath_Step + + for _, step := range path { + switch s := step.(type) { + case cty.GetAttrStep: + steps = append(steps, &proto.AttributePath_Step{ + Selector: &proto.AttributePath_Step_AttributeName{ + AttributeName: s.Name, + }, + }) + case cty.IndexStep: + ty := s.Key.Type() + switch ty { + case cty.Number: + i, _ := s.Key.AsBigFloat().Int64() + steps = append(steps, &proto.AttributePath_Step{ + Selector: &proto.AttributePath_Step_ElementKeyInt{ + ElementKeyInt: i, + }, + }) + case cty.String: + steps = append(steps, &proto.AttributePath_Step{ + Selector: &proto.AttributePath_Step_ElementKeyString{ + ElementKeyString: s.Key.AsString(), + }, + }) + } + } + } + + return &proto.AttributePath{Steps: steps} +} diff --git a/helper/plugin/grpc_provider_test.go b/helper/plugin/grpc_provider_test.go new file mode 100644 index 0000000000..89ee290b98 --- /dev/null +++ b/helper/plugin/grpc_provider_test.go @@ -0,0 +1,295 @@ +package plugin + +import ( + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/hashicorp/terraform/helper/schema" + "github.com/hashicorp/terraform/plugin/proto" + "github.com/hashicorp/terraform/terraform" + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/msgpack" +) + +// The GRPCProviderServer will directly implement the go protobuf server +var _ proto.ProviderServer = (*GRPCProviderServer)(nil) + +var ( + typeComparer = cmp.Comparer(cty.Type.Equals) + valueComparer = cmp.Comparer(cty.Value.RawEquals) + equateEmpty = cmpopts.EquateEmpty() +) + +func TestUpgradeState_jsonState(t *testing.T) { + r := &schema.Resource{ + SchemaVersion: 2, + Schema: map[string]*schema.Schema{ + "two": { + Type: schema.TypeInt, + Optional: true, + }, + }, + } + + r.StateUpgraders = []schema.StateUpgrader{ + { + Version: 0, + Type: cty.Object(map[string]cty.Type{ + "id": cty.String, + "zero": cty.Number, + }), + Upgrade: func(m map[string]interface{}, meta interface{}) (map[string]interface{}, error) { + _, ok := m["zero"].(float64) + if !ok { + return nil, fmt.Errorf("zero not found in %#v", m) + } + m["one"] = float64(1) + delete(m, "zero") + return m, nil + }, + }, + { + Version: 1, + Type: cty.Object(map[string]cty.Type{ + "id": cty.String, + "one": cty.Number, + }), + Upgrade: func(m map[string]interface{}, meta interface{}) (map[string]interface{}, error) { + _, ok := m["one"].(float64) + if !ok { + return nil, fmt.Errorf("one not found in %#v", m) + } + m["two"] = float64(2) + delete(m, "one") + return m, nil + }, + }, + } + + server := &GRPCProviderServer{ + provider: &schema.Provider{ + ResourcesMap: map[string]*schema.Resource{ + "test": r, + }, + }, + } + + req := &proto.UpgradeResourceState_Request{ + TypeName: "test", + Version: 0, + RawState: &proto.RawState{ + Json: []byte(`{"id":"bar","zero":0}`), + }, + } + + resp, err := server.UpgradeResourceState(nil, req) + if err != nil { + t.Fatal(err) + } + + if len(resp.Diagnostics) > 0 { + for _, d := range resp.Diagnostics { + t.Errorf("%#v", d) + } + t.Fatal("error") + } + + val, err := msgpack.Unmarshal(resp.UpgradedState.Msgpack, r.CoreConfigSchema().ImpliedType()) + if err != nil { + t.Fatal(err) + } + + expected := cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("bar"), + "two": cty.NumberIntVal(2), + }) + + if !cmp.Equal(expected, val, valueComparer, equateEmpty) { + t.Fatal(cmp.Diff(expected, val, valueComparer, equateEmpty)) + } +} + +func TestUpgradeState_flatmapState(t *testing.T) { + r := &schema.Resource{ + SchemaVersion: 4, + Schema: map[string]*schema.Schema{ + "four": { + Type: schema.TypeInt, + Required: true, + }, + }, + // this MigrateState will take the state to version 2 + MigrateState: func(v int, is *terraform.InstanceState, _ interface{}) (*terraform.InstanceState, error) { + switch v { + case 0: + _, ok := is.Attributes["zero"] + if !ok { + return nil, fmt.Errorf("zero not found in %#v", is.Attributes) + } + is.Attributes["one"] = "1" + delete(is.Attributes, "zero") + fallthrough + case 1: + _, ok := is.Attributes["one"] + if !ok { + return nil, fmt.Errorf("one not found in %#v", is.Attributes) + } + is.Attributes["two"] = "2" + delete(is.Attributes, "one") + default: + return nil, fmt.Errorf("invalid schema version %d", v) + } + return is, nil + }, + } + + r.StateUpgraders = []schema.StateUpgrader{ + { + Version: 2, + Type: cty.Object(map[string]cty.Type{ + "id": cty.String, + "two": cty.Number, + }), + Upgrade: func(m map[string]interface{}, meta interface{}) (map[string]interface{}, error) { + _, ok := m["two"].(float64) + if !ok { + return nil, fmt.Errorf("two not found in %#v", m) + } + m["three"] = float64(3) + delete(m, "two") + return m, nil + }, + }, + { + Version: 3, + Type: cty.Object(map[string]cty.Type{ + "id": cty.String, + "three": cty.Number, + }), + Upgrade: func(m map[string]interface{}, meta interface{}) (map[string]interface{}, error) { + _, ok := m["three"].(float64) + if !ok { + return nil, fmt.Errorf("three not found in %#v", m) + } + m["four"] = float64(4) + delete(m, "three") + return m, nil + }, + }, + } + + server := &GRPCProviderServer{ + provider: &schema.Provider{ + ResourcesMap: map[string]*schema.Resource{ + "test": r, + }, + }, + } + + testReqs := []*proto.UpgradeResourceState_Request{ + { + TypeName: "test", + Version: 0, + RawState: &proto.RawState{ + Flatmap: map[string]string{ + "id": "bar", + "zero": "0", + }, + }, + }, + { + TypeName: "test", + Version: 1, + RawState: &proto.RawState{ + Flatmap: map[string]string{ + "id": "bar", + "one": "1", + }, + }, + }, + // two and up could be stored in flatmap or json states + { + TypeName: "test", + Version: 2, + RawState: &proto.RawState{ + Flatmap: map[string]string{ + "id": "bar", + "two": "2", + }, + }, + }, + { + TypeName: "test", + Version: 2, + RawState: &proto.RawState{ + Json: []byte(`{"id":"bar","two":2}`), + }, + }, + { + TypeName: "test", + Version: 3, + RawState: &proto.RawState{ + Flatmap: map[string]string{ + "id": "bar", + "three": "3", + }, + }, + }, + { + TypeName: "test", + Version: 3, + RawState: &proto.RawState{ + Json: []byte(`{"id":"bar","three":3}`), + }, + }, + { + TypeName: "test", + Version: 4, + RawState: &proto.RawState{ + Flatmap: map[string]string{ + "id": "bar", + "four": "4", + }, + }, + }, + { + TypeName: "test", + Version: 4, + RawState: &proto.RawState{ + Json: []byte(`{"id":"bar","four":4}`), + }, + }, + } + + for i, req := range testReqs { + t.Run(fmt.Sprintf("%d-%d", i, req.Version), func(t *testing.T) { + resp, err := server.UpgradeResourceState(nil, req) + if err != nil { + t.Fatal(err) + } + + if len(resp.Diagnostics) > 0 { + for _, d := range resp.Diagnostics { + t.Errorf("%#v", d) + } + t.Fatal("error") + } + + val, err := msgpack.Unmarshal(resp.UpgradedState.Msgpack, r.CoreConfigSchema().ImpliedType()) + if err != nil { + t.Fatal(err) + } + + expected := cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("bar"), + "four": cty.NumberIntVal(4), + }) + + if !cmp.Equal(expected, val, valueComparer, equateEmpty) { + t.Fatal(cmp.Diff(expected, val, valueComparer, equateEmpty)) + } + }) + } +} diff --git a/helper/plugin/grpc_provisioner.go b/helper/plugin/grpc_provisioner.go new file mode 100644 index 0000000000..b3f07673e3 --- /dev/null +++ b/helper/plugin/grpc_provisioner.go @@ -0,0 +1,132 @@ +package plugin + +import ( + "log" + + "github.com/hashicorp/terraform/helper/schema" + "github.com/hashicorp/terraform/plugin/proto" + "github.com/hashicorp/terraform/terraform" + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/convert" + "github.com/zclconf/go-cty/cty/msgpack" + context "golang.org/x/net/context" +) + +type GRPCProvisionerServer struct { + provisioner *schema.Provisioner +} + +func (s *GRPCProvisionerServer) GetSchema(_ context.Context, req *proto.GetProvisionerSchema_Request) (*proto.GetProvisionerSchema_Response, error) { + resp := &proto.GetProvisionerSchema_Response{} + + resp.Provisioner = &proto.Schema{ + Block: protoSchemaBlock(schema.InternalMap(s.provisioner.Schema).CoreConfigSchema()), + } + + return resp, nil +} + +func (s *GRPCProvisionerServer) ValidateProvisionerConfig(_ context.Context, req *proto.ValidateProvisionerConfig_Request) (*proto.ValidateProvisionerConfig_Response, error) { + resp := &proto.ValidateProvisionerConfig_Response{} + + cfgSchema := schema.InternalMap(s.provisioner.Schema).CoreConfigSchema() + + configVal, err := msgpack.Unmarshal(req.Config.Msgpack, cfgSchema.ImpliedType()) + if err != nil { + resp.Diagnostics = appendDiag(resp.Diagnostics, err) + return resp, nil + } + + config := terraform.NewResourceConfigShimmed(configVal, cfgSchema) + + warns, errs := s.provisioner.Validate(config) + resp.Diagnostics = appendDiag(resp.Diagnostics, diagsFromWarnsErrs(warns, errs)) + + return resp, nil +} + +// stringMapFromValue converts a cty.Value to a map[stirng]string. +// This will panic if the val is not a cty.Map(cty.String). +func stringMapFromValue(val cty.Value) map[string]string { + m := map[string]string{} + if val.IsNull() || !val.IsKnown() { + return m + } + + for it := val.ElementIterator(); it.Next(); { + ak, av := it.Element() + name := ak.AsString() + + if !av.IsKnown() || av.IsNull() { + continue + } + + av, _ = convert.Convert(av, cty.String) + m[name] = av.AsString() + } + + return m +} + +// uiOutput implements the terraform.UIOutput interface to adapt the grpc +// stream to the legacy Provisioner.Apply method. +type uiOutput struct { + srv proto.Provisioner_ProvisionResourceServer +} + +func (o uiOutput) Output(s string) { + err := o.srv.Send(&proto.ProvisionResource_Response{ + Output: s, + }) + if err != nil { + log.Printf("[ERROR] %s", err) + } +} + +func (s *GRPCProvisionerServer) ProvisionResource(req *proto.ProvisionResource_Request, srv proto.Provisioner_ProvisionResourceServer) error { + // We send back a diagnostics over the stream if there was a + // provisioner-side problem. + srvResp := &proto.ProvisionResource_Response{} + + cfgSchema := schema.InternalMap(s.provisioner.Schema).CoreConfigSchema() + cfgVal, err := msgpack.Unmarshal(req.Config.Msgpack, cfgSchema.ImpliedType()) + if err != nil { + srvResp.Diagnostics = appendDiag(srvResp.Diagnostics, err) + srv.Send(srvResp) + return nil + } + resourceConfig := terraform.NewResourceConfigShimmed(cfgVal, cfgSchema) + + connVal, err := msgpack.Unmarshal(req.Connection.Msgpack, cty.Map(cty.String)) + if err != nil { + srvResp.Diagnostics = appendDiag(srvResp.Diagnostics, err) + srv.Send(srvResp) + return nil + } + + conn := stringMapFromValue(connVal) + + instanceState := &terraform.InstanceState{ + Ephemeral: terraform.EphemeralState{ + ConnInfo: conn, + }, + } + + err = s.provisioner.Apply(uiOutput{srv}, instanceState, resourceConfig) + if err != nil { + srvResp.Diagnostics = appendDiag(srvResp.Diagnostics, err) + srv.Send(srvResp) + } + return nil +} + +func (s *GRPCProvisionerServer) Stop(_ context.Context, req *proto.Stop_Request) (*proto.Stop_Response, error) { + resp := &proto.Stop_Response{} + + err := s.provisioner.Stop() + if err != nil { + resp.Error = err.Error() + } + + return resp, nil +} diff --git a/helper/plugin/grpc_provisioner_test.go b/helper/plugin/grpc_provisioner_test.go new file mode 100644 index 0000000000..ebd712d60d --- /dev/null +++ b/helper/plugin/grpc_provisioner_test.go @@ -0,0 +1,5 @@ +package plugin + +import "github.com/hashicorp/terraform/plugin/proto" + +var _ proto.ProvisionerServer = (*GRPCProvisionerServer)(nil) diff --git a/helper/plugin/schema.go b/helper/plugin/schema.go new file mode 100644 index 0000000000..d5a12db717 --- /dev/null +++ b/helper/plugin/schema.go @@ -0,0 +1,70 @@ +package plugin + +import ( + "encoding/json" + "reflect" + "sort" + + "github.com/hashicorp/terraform/configs/configschema" + "github.com/hashicorp/terraform/plugin/proto" +) + +// protoSchemaBlock takes a *configschema.Block and converts it to a +// proto.Schema_Block for a grpc response. +func protoSchemaBlock(b *configschema.Block) *proto.Schema_Block { + block := &proto.Schema_Block{} + + for _, name := range sortedKeys(b.Attributes) { + a := b.Attributes[name] + attr := &proto.Schema_Attribute{ + Name: name, + Description: a.Description, + Optional: a.Optional, + Computed: a.Computed, + Required: a.Required, + Sensitive: a.Sensitive, + } + + ty, err := json.Marshal(a.Type) + if err != nil { + panic(err) + } + + attr.Type = ty + + block.Attributes = append(block.Attributes, attr) + } + + for _, name := range sortedKeys(b.BlockTypes) { + b := b.BlockTypes[name] + block.BlockTypes = append(block.BlockTypes, protoSchemaNestedBlock(name, b)) + } + + return block +} + +func protoSchemaNestedBlock(name string, b *configschema.NestedBlock) *proto.Schema_NestedBlock { + return &proto.Schema_NestedBlock{ + TypeName: name, + Block: protoSchemaBlock(&b.Block), + Nesting: proto.Schema_NestedBlock_NestingMode(b.Nesting), + MinItems: int64(b.MinItems), + MaxItems: int64(b.MaxItems), + } +} + +// sortedKeys returns the lexically sorted keys from the given map. This is +// used to make schema conversions are deterministic. This panics if map keys +// are not a string. +func sortedKeys(m interface{}) []string { + v := reflect.ValueOf(m) + keys := make([]string, v.Len()) + + mapKeys := v.MapKeys() + for i, k := range mapKeys { + keys[i] = k.Interface().(string) + } + + sort.Strings(keys) + return keys +} diff --git a/helper/plugin/schema_provider_test.go b/helper/plugin/schema_provider_test.go new file mode 100644 index 0000000000..d8c855e047 --- /dev/null +++ b/helper/plugin/schema_provider_test.go @@ -0,0 +1,516 @@ +package plugin + +import ( + "errors" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform/config/hcl2shim" + "github.com/hashicorp/terraform/configs/configschema" + "github.com/hashicorp/terraform/helper/schema" + "github.com/hashicorp/terraform/plugin" + "github.com/hashicorp/terraform/plugin/proto" + "github.com/hashicorp/terraform/providers" + "github.com/hashicorp/terraform/tfdiags" + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/msgpack" +) + +// the TestProvider functions have been adapted from the helper/schema fixtures + +func TestProviderGetSchema(t *testing.T) { + p := &schema.Provider{ + Schema: map[string]*schema.Schema{ + "bar": { + Type: schema.TypeString, + Required: true, + }, + }, + ResourcesMap: map[string]*schema.Resource{ + "foo": &schema.Resource{ + SchemaVersion: 1, + Schema: map[string]*schema.Schema{ + "bar": { + Type: schema.TypeString, + Required: true, + }, + }, + }, + }, + DataSourcesMap: map[string]*schema.Resource{ + "baz": &schema.Resource{ + SchemaVersion: 2, + Schema: map[string]*schema.Schema{ + "bur": { + Type: schema.TypeString, + Required: true, + }, + }, + }, + }, + } + + want := providers.GetSchemaResponse{ + Provider: providers.Schema{ + Version: 0, + Block: schema.InternalMap(p.Schema).CoreConfigSchema(), + }, + ResourceTypes: map[string]providers.Schema{ + "foo": { + Version: 1, + Block: p.ResourcesMap["foo"].CoreConfigSchema(), + }, + }, + DataSources: map[string]providers.Schema{ + "baz": { + Version: 2, + Block: p.DataSourcesMap["baz"].CoreConfigSchema(), + }, + }, + } + + provider := &GRPCProviderServer{ + provider: p, + } + + resp, err := provider.GetSchema(nil, nil) + if err != nil { + t.Fatalf("unexpected error %s", err) + } + diags := plugin.ProtoToDiagnostics(resp.Diagnostics) + if diags.HasErrors() { + t.Fatal(diags.Err()) + } + + schemaResp := providers.GetSchemaResponse{ + Provider: plugin.ProtoToProviderSchema(resp.Provider), + ResourceTypes: map[string]providers.Schema{ + "foo": plugin.ProtoToProviderSchema(resp.ResourceSchemas["foo"]), + }, + DataSources: map[string]providers.Schema{ + "baz": plugin.ProtoToProviderSchema(resp.DataSourceSchemas["baz"]), + }, + } + + if !cmp.Equal(schemaResp, want, equateEmpty, typeComparer) { + t.Error("wrong result:\n", cmp.Diff(schemaResp, want, equateEmpty, typeComparer)) + } +} + +func TestProviderValidate(t *testing.T) { + cases := []struct { + Name string + P *schema.Provider + Err bool + Warn bool + }{ + { + Name: "warning", + P: &schema.Provider{ + Schema: map[string]*schema.Schema{ + "foo": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + ValidateFunc: func(_ interface{}, _ string) ([]string, []error) { + return []string{"warning"}, nil + }, + }, + }, + }, + Warn: true, + }, + { + Name: "error", + P: &schema.Provider{ + Schema: map[string]*schema.Schema{ + "foo": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + ValidateFunc: func(_ interface{}, _ string) ([]string, []error) { + return nil, []error{errors.New("error")} + }, + }, + }, + }, + Err: true, + }, + } + + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { + provider := &GRPCProviderServer{ + provider: tc.P, + } + + cfgSchema := schema.InternalMap(tc.P.Schema).CoreConfigSchema() + val := hcl2shim.HCL2ValueFromConfigValue(map[string]interface{}{"foo": "bar"}) + val, err := cfgSchema.CoerceValue(val) + if err != nil { + t.Fatal(err) + } + + mp, err := msgpack.Marshal(val, cfgSchema.ImpliedType()) + if err != nil { + t.Fatal(err) + } + + req := &proto.ValidateProviderConfig_Request{ + Config: &proto.DynamicValue{Msgpack: mp}, + } + + resp, err := provider.ValidateProviderConfig(nil, req) + if err != nil { + t.Fatal(err) + } + + diags := plugin.ProtoToDiagnostics(resp.Diagnostics) + + var warn tfdiags.Diagnostic + for _, d := range diags { + if d.Severity() == tfdiags.Warning { + warn = d + } + } + + switch { + case tc.Err: + if !diags.HasErrors() { + t.Fatal("expected error") + } + case !tc.Err: + if diags.HasErrors() { + t.Fatal(diags.Err()) + } + + case tc.Warn: + if warn == nil { + t.Fatal("expected warning") + } + case !tc.Warn: + if warn != nil { + t.Fatal("unexpected warning", warn) + } + } + }) + } +} + +func TestProviderValidateResource(t *testing.T) { + cases := []struct { + Name string + P *schema.Provider + Type string + Config map[string]interface{} + Err bool + Warn bool + }{ + { + Name: "error", + P: &schema.Provider{ + ResourcesMap: map[string]*schema.Resource{ + "foo": &schema.Resource{ + Schema: map[string]*schema.Schema{ + "attr": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + ValidateFunc: func(_ interface{}, _ string) ([]string, []error) { + return nil, []error{errors.New("warn")} + }, + }, + }, + }, + }, + }, + Type: "foo", + Err: true, + }, + { + Name: "ok", + P: &schema.Provider{ + ResourcesMap: map[string]*schema.Resource{ + "foo": &schema.Resource{ + Schema: map[string]*schema.Schema{ + "attr": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + }, + }, + }, + }, + Config: map[string]interface{}{"attr": "bar"}, + Type: "foo", + }, + { + Name: "warn", + P: &schema.Provider{ + ResourcesMap: map[string]*schema.Resource{ + "foo": &schema.Resource{ + Schema: map[string]*schema.Schema{ + "attr": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + ValidateFunc: func(_ interface{}, _ string) ([]string, []error) { + return []string{"warn"}, nil + }, + }, + }, + }, + }, + }, + Type: "foo", + Config: map[string]interface{}{"attr": "bar"}, + Err: false, + }, + } + + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { + provider := &GRPCProviderServer{ + provider: tc.P, + } + + cfgSchema := tc.P.ResourcesMap[tc.Type].CoreConfigSchema() + val := hcl2shim.HCL2ValueFromConfigValue(tc.Config) + val, err := cfgSchema.CoerceValue(val) + if err != nil { + t.Fatal(err) + } + + mp, err := msgpack.Marshal(val, cfgSchema.ImpliedType()) + if err != nil { + t.Fatal(err) + } + + req := &proto.ValidateResourceTypeConfig_Request{ + TypeName: tc.Type, + Config: &proto.DynamicValue{Msgpack: mp}, + } + + resp, err := provider.ValidateResourceTypeConfig(nil, req) + if err != nil { + t.Fatal(err) + } + + diags := plugin.ProtoToDiagnostics(resp.Diagnostics) + + var warn tfdiags.Diagnostic + for _, d := range diags { + if d.Severity() == tfdiags.Warning { + warn = d + } + } + + switch { + case tc.Err: + if !diags.HasErrors() { + t.Fatal("expected error") + } + case !tc.Err: + if diags.HasErrors() { + t.Fatal(diags.Err()) + } + + case tc.Warn: + if warn == nil { + t.Fatal("expected warning") + } + case !tc.Warn: + if warn != nil { + t.Fatal("unexpected warning", warn) + } + } + }) + } +} + +func TestProviderImportState_default(t *testing.T) { + + p := &GRPCProviderServer{ + provider: &schema.Provider{ + ResourcesMap: map[string]*schema.Resource{ + "foo": &schema.Resource{ + Importer: &schema.ResourceImporter{}, + }, + }, + }, + } + + req := &proto.ImportResourceState_Request{ + TypeName: "foo", + Id: "bar", + } + resp, err := p.ImportResourceState(nil, req) + if err != nil { + t.Fatal(err) + } + diags := plugin.ProtoToDiagnostics(resp.Diagnostics) + if diags.HasErrors() { + t.Fatal(diags.Err()) + } + + if len(resp.ImportedResources) != 1 { + t.Fatalf("expected 1 import, git %#v", resp.ImportedResources) + } +} + +func TestProviderImportState_setsId(t *testing.T) { + var val string + stateFunc := func(d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { + val = d.Id() + return []*schema.ResourceData{d}, nil + } + + p := &GRPCProviderServer{ + provider: &schema.Provider{ + ResourcesMap: map[string]*schema.Resource{ + "foo": &schema.Resource{ + Importer: &schema.ResourceImporter{ + State: stateFunc, + }, + }, + }, + }, + } + + req := &proto.ImportResourceState_Request{ + TypeName: "foo", + Id: "bar", + } + resp, err := p.ImportResourceState(nil, req) + if err != nil { + t.Fatal(err) + } + diags := plugin.ProtoToDiagnostics(resp.Diagnostics) + if diags.HasErrors() { + t.Fatal(diags.Err()) + } + + if len(resp.ImportedResources) != 1 { + t.Fatalf("expected 1 import, git %#v", resp.ImportedResources) + } + + if val != "bar" { + t.Fatal("should set id") + } +} + +func TestProviderImportState_setsType(t *testing.T) { + var tVal string + stateFunc := func(d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { + d.SetId("foo") + tVal = d.State().Ephemeral.Type + return []*schema.ResourceData{d}, nil + } + + p := &GRPCProviderServer{ + provider: &schema.Provider{ + ResourcesMap: map[string]*schema.Resource{ + "foo": &schema.Resource{ + Importer: &schema.ResourceImporter{ + State: stateFunc, + }, + }, + }, + }, + } + + req := &proto.ImportResourceState_Request{ + TypeName: "foo", + Id: "bar", + } + resp, err := p.ImportResourceState(nil, req) + if err != nil { + t.Fatal(err) + } + diags := plugin.ProtoToDiagnostics(resp.Diagnostics) + if diags.HasErrors() { + t.Fatal(diags.Err()) + } + + if tVal != "foo" { + t.Fatal("should set type") + } +} + +func TestProviderStop(t *testing.T) { + var p schema.Provider + + if p.Stopped() { + t.Fatal("should not be stopped") + } + + // Verify stopch blocks + ch := p.StopContext().Done() + select { + case <-ch: + t.Fatal("should not be stopped") + case <-time.After(10 * time.Millisecond): + } + + provider := &GRPCProviderServer{ + provider: &p, + } + + // Stop it + if _, err := provider.Stop(nil, &proto.Stop_Request{}); err != nil { + t.Fatal(err) + } + + // Verify + if !p.Stopped() { + t.Fatal("should be stopped") + } + + select { + case <-ch: + case <-time.After(10 * time.Millisecond): + t.Fatal("should be stopped") + } +} + +func TestProviderStop_stopFirst(t *testing.T) { + var p schema.Provider + + provider := &GRPCProviderServer{ + provider: &p, + } + + // Stop it + _, err := provider.Stop(nil, &proto.Stop_Request{}) + if err != nil { + t.Fatal(err) + } + + // Verify + if !p.Stopped() { + t.Fatal("should be stopped") + } + + select { + case <-p.StopContext().Done(): + case <-time.After(10 * time.Millisecond): + t.Fatal("should be stopped") + } +} + +// add the implicit "id" attribute for test resources +func testResource(block *configschema.Block) *configschema.Block { + if block.Attributes == nil { + block.Attributes = make(map[string]*configschema.Attribute) + } + + if block.BlockTypes == nil { + block.BlockTypes = make(map[string]*configschema.NestedBlock) + } + + if block.Attributes["id"] == nil { + block.Attributes["id"] = &configschema.Attribute{ + Type: cty.String, + Optional: true, + Computed: true, + } + } + return block +} diff --git a/helper/plugin/schema_provisioner_test.go b/helper/plugin/schema_provisioner_test.go new file mode 100644 index 0000000000..5e9dd47793 --- /dev/null +++ b/helper/plugin/schema_provisioner_test.go @@ -0,0 +1,338 @@ +package plugin + +import ( + "context" + "fmt" + "reflect" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/hashicorp/terraform/config/hcl2shim" + "github.com/hashicorp/terraform/helper/schema" + "github.com/hashicorp/terraform/plugin" + "github.com/hashicorp/terraform/plugin/proto" + "github.com/hashicorp/terraform/terraform" + "github.com/hashicorp/terraform/tfdiags" + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/msgpack" + + mockproto "github.com/hashicorp/terraform/plugin/mock_proto" +) + +// TestProvisioner functions in this file have been adapted from the +// helper/schema tests. + +func noopApply(ctx context.Context) error { + return nil +} + +func TestProvisionerValidate(t *testing.T) { + cases := []struct { + Name string + P *schema.Provisioner + Config map[string]interface{} + Err bool + Warns []string + }{ + { + Name: "No ApplyFunc", + P: &schema.Provisioner{}, + Config: map[string]interface{}{}, + Err: true, + }, + { + "Basic required field set", + &schema.Provisioner{ + Schema: map[string]*schema.Schema{ + "foo": &schema.Schema{ + Required: true, + Type: schema.TypeString, + }, + }, + ApplyFunc: noopApply, + }, + map[string]interface{}{ + "foo": "bar", + }, + false, + nil, + }, + { + Name: "Warning from property validation", + P: &schema.Provisioner{ + Schema: map[string]*schema.Schema{ + "foo": { + Type: schema.TypeString, + Optional: true, + ValidateFunc: func(v interface{}, k string) (ws []string, errors []error) { + ws = append(ws, "Simple warning from property validation") + return + }, + }, + }, + ApplyFunc: noopApply, + }, + Config: map[string]interface{}{ + "foo": "", + }, + Err: false, + Warns: []string{"Simple warning from property validation"}, + }, + { + Name: "No schema", + P: &schema.Provisioner{ + Schema: nil, + ApplyFunc: noopApply, + }, + Config: map[string]interface{}{}, + Err: false, + }, + { + Name: "Warning from provisioner ValidateFunc", + P: &schema.Provisioner{ + Schema: nil, + ApplyFunc: noopApply, + ValidateFunc: func(*terraform.ResourceConfig) (ws []string, errors []error) { + ws = append(ws, "Simple warning from provisioner ValidateFunc") + return + }, + }, + Config: map[string]interface{}{}, + Err: false, + Warns: []string{"Simple warning from provisioner ValidateFunc"}, + }, + } + + for i, tc := range cases { + t.Run(fmt.Sprintf("%d-%s", i, tc.Name), func(t *testing.T) { + p := &GRPCProvisionerServer{ + provisioner: tc.P, + } + + cfgSchema := schema.InternalMap(tc.P.Schema).CoreConfigSchema() + val := hcl2shim.HCL2ValueFromConfigValue(tc.Config) + + val, err := cfgSchema.CoerceValue(val) + if err != nil { + t.Fatal(err) + } + + mp, err := msgpack.Marshal(val, cfgSchema.ImpliedType()) + if err != nil { + t.Fatal(err) + } + + req := &proto.ValidateProvisionerConfig_Request{ + Config: &proto.DynamicValue{Msgpack: mp}, + } + + resp, err := p.ValidateProvisionerConfig(nil, req) + if err != nil { + t.Fatal(err) + } + + diags := plugin.ProtoToDiagnostics(resp.Diagnostics) + + if diags.HasErrors() != tc.Err { + t.Fatal(diags.Err()) + } + + var ws []string + for _, d := range diags { + if d.Severity() == tfdiags.Warning { + ws = append(ws, d.Description().Summary) + } + } + + if (tc.Warns != nil || len(ws) != 0) && !reflect.DeepEqual(ws, tc.Warns) { + t.Fatalf("%d: warnings mismatch, actual: %#v", i, ws) + } + }) + } +} + +func TestProvisionerApply(t *testing.T) { + cases := []struct { + Name string + P *schema.Provisioner + Conn map[string]interface{} + Config map[string]interface{} + Err bool + }{ + { + Name: "Basic config", + P: &schema.Provisioner{ + ConnSchema: map[string]*schema.Schema{ + "foo": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + }, + + Schema: map[string]*schema.Schema{ + "foo": &schema.Schema{ + Type: schema.TypeInt, + Optional: true, + }, + }, + + ApplyFunc: func(ctx context.Context) error { + cd := ctx.Value(schema.ProvConnDataKey).(*schema.ResourceData) + d := ctx.Value(schema.ProvConfigDataKey).(*schema.ResourceData) + if d.Get("foo").(int) != 42 { + return fmt.Errorf("bad config data") + } + if cd.Get("foo").(string) != "bar" { + return fmt.Errorf("bad conn data") + } + + return nil + }, + }, + Conn: map[string]interface{}{ + "foo": "bar", + }, + Config: map[string]interface{}{ + "foo": 42, + }, + Err: false, + }, + } + + for i, tc := range cases { + t.Run(fmt.Sprintf("%d-%s", i, tc.Name), func(t *testing.T) { + p := &GRPCProvisionerServer{ + provisioner: tc.P, + } + + cfgSchema := schema.InternalMap(tc.P.Schema).CoreConfigSchema() + val := hcl2shim.HCL2ValueFromConfigValue(tc.Config) + + val, err := cfgSchema.CoerceValue(val) + if err != nil { + t.Fatal(err) + } + + cfgMP, err := msgpack.Marshal(val, cfgSchema.ImpliedType()) + if err != nil { + t.Fatal(err) + } + + connVal := hcl2shim.HCL2ValueFromConfigValue(tc.Conn) + + connMP, err := msgpack.Marshal(connVal, cty.Map(cty.String)) + if err != nil { + t.Fatal(err) + } + + req := &proto.ProvisionResource_Request{ + Config: &proto.DynamicValue{Msgpack: cfgMP}, + Connection: &proto.DynamicValue{Msgpack: connMP}, + } + + ctrl := gomock.NewController(t) + srv := mockproto.NewMockProvisioner_ProvisionResourceServer(ctrl) + srv.EXPECT().Send(gomock.Any()).Return(nil) + + err = p.ProvisionResource(req, srv) + if err != nil && !tc.Err { + t.Fatal(err) + } + }) + } +} + +func TestProvisionerStop(t *testing.T) { + p := &GRPCProvisionerServer{ + provisioner: &schema.Provisioner{}, + } + + // Verify stopch blocks + ch := p.provisioner.StopContext().Done() + select { + case <-ch: + t.Fatal("should not be stopped") + case <-time.After(10 * time.Millisecond): + } + + // Stop it + resp, err := p.Stop(nil, nil) + if err != nil { + t.Fatal(err) + } + + if resp.Error != "" { + t.Fatal(resp.Error) + } + + select { + case <-ch: + case <-time.After(10 * time.Millisecond): + t.Fatal("should be stopped") + } +} + +func TestProvisionerStop_apply(t *testing.T) { + p := &schema.Provisioner{ + ConnSchema: map[string]*schema.Schema{ + "foo": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + }, + + Schema: map[string]*schema.Schema{ + "foo": &schema.Schema{ + Type: schema.TypeInt, + Optional: true, + }, + }, + + ApplyFunc: func(ctx context.Context) error { + <-ctx.Done() + return nil + }, + } + + s := &GRPCProvisionerServer{ + provisioner: p, + } + srv := mockproto.NewMockProvisioner_ProvisionResourceServer(gomock.NewController(t)) + srv.EXPECT().Send(gomock.Any()).Return(nil) + + // Run the apply in a goroutine + doneCh := make(chan struct{}) + go func() { + req := &proto.ProvisionResource_Request{ + Config: &proto.DynamicValue{Msgpack: []byte("\201\243foo*")}, + Connection: &proto.DynamicValue{Msgpack: []byte("\201\243foo\243bar")}, + } + err := s.ProvisionResource(req, srv) + if err != nil { + t.Fatal(err) + } + close(doneCh) + }() + + // Should block + select { + case <-doneCh: + t.Fatal("should not be done") + case <-time.After(10 * time.Millisecond): + } + + resp, err := s.Stop(nil, nil) + if err != nil { + t.Fatal(err) + } + if resp.Error != "" { + t.Fatal(resp.Error) + } + + select { + case <-doneCh: + case <-time.After(10 * time.Millisecond): + t.Fatal("should be done") + } +} diff --git a/helper/plugin/schema_test.go b/helper/plugin/schema_test.go new file mode 100644 index 0000000000..d46924a59b --- /dev/null +++ b/helper/plugin/schema_test.go @@ -0,0 +1,182 @@ +package plugin + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform/configs/configschema" + "github.com/hashicorp/terraform/plugin/proto" + "github.com/zclconf/go-cty/cty" +) + +// Test that we can convert configschema to protobuf types and back again. +func TestConvertSchemaBlocks(t *testing.T) { + tests := map[string]struct { + Want *proto.Schema_Block + Block *configschema.Block + }{ + "attributes": { + &proto.Schema_Block{ + Attributes: []*proto.Schema_Attribute{ + { + Name: "computed", + Type: []byte(`["list","bool"]`), + Computed: true, + }, + { + Name: "optional", + Type: []byte(`"string"`), + Optional: true, + }, + { + Name: "optional_computed", + Type: []byte(`["map","bool"]`), + Optional: true, + Computed: true, + }, + { + Name: "required", + Type: []byte(`"number"`), + Required: true, + }, + }, + }, + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "computed": { + Type: cty.List(cty.Bool), + Computed: true, + }, + "optional": { + Type: cty.String, + Optional: true, + }, + "optional_computed": { + Type: cty.Map(cty.Bool), + Optional: true, + Computed: true, + }, + "required": { + Type: cty.Number, + Required: true, + }, + }, + }, + }, + "blocks": { + &proto.Schema_Block{ + BlockTypes: []*proto.Schema_NestedBlock{ + { + TypeName: "list", + Nesting: proto.Schema_NestedBlock_LIST, + Block: &proto.Schema_Block{}, + }, + { + TypeName: "map", + Nesting: proto.Schema_NestedBlock_MAP, + Block: &proto.Schema_Block{}, + }, + { + TypeName: "set", + Nesting: proto.Schema_NestedBlock_SET, + Block: &proto.Schema_Block{}, + }, + { + TypeName: "single", + Nesting: proto.Schema_NestedBlock_SINGLE, + Block: &proto.Schema_Block{ + Attributes: []*proto.Schema_Attribute{ + { + Name: "foo", + Type: []byte(`"dynamic"`), + Required: true, + }, + }, + }, + }, + }, + }, + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "list": &configschema.NestedBlock{ + Nesting: configschema.NestingList, + }, + "map": &configschema.NestedBlock{ + Nesting: configschema.NestingMap, + }, + "set": &configschema.NestedBlock{ + Nesting: configschema.NestingSet, + }, + "single": &configschema.NestedBlock{ + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "foo": { + Type: cty.DynamicPseudoType, + Required: true, + }, + }, + }, + }, + }, + }, + }, + "deep block nesting": { + &proto.Schema_Block{ + BlockTypes: []*proto.Schema_NestedBlock{ + { + TypeName: "single", + Nesting: proto.Schema_NestedBlock_SINGLE, + Block: &proto.Schema_Block{ + BlockTypes: []*proto.Schema_NestedBlock{ + { + TypeName: "list", + Nesting: proto.Schema_NestedBlock_LIST, + Block: &proto.Schema_Block{ + BlockTypes: []*proto.Schema_NestedBlock{ + { + TypeName: "set", + Nesting: proto.Schema_NestedBlock_SET, + Block: &proto.Schema_Block{}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "single": &configschema.NestedBlock{ + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "list": &configschema.NestedBlock{ + Nesting: configschema.NestingList, + Block: configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "set": &configschema.NestedBlock{ + Nesting: configschema.NestingSet, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + converted := protoSchemaBlock(tc.Block) + if !cmp.Equal(converted, tc.Want, typeComparer, equateEmpty) { + t.Fatal(cmp.Diff(converted, tc.Want, typeComparer, equateEmpty)) + } + }) + } +}