diff --git a/go.mod b/go.mod index 8198b7670..a3a0950b8 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,8 @@ require ( github.com/elastic/go-elasticsearch/v7 v7.17.7 github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320 github.com/hashicorp/go-version v1.6.0 + github.com/hashicorp/terraform-plugin-framework v1.0.0 + github.com/hashicorp/terraform-plugin-framework-validators v0.8.0 github.com/hashicorp/terraform-plugin-go v0.14.3 github.com/hashicorp/terraform-plugin-log v0.8.0 github.com/hashicorp/terraform-plugin-mux v0.9.0 diff --git a/go.sum b/go.sum index 5686d1b81..b17d72bab 100644 --- a/go.sum +++ b/go.sum @@ -129,6 +129,10 @@ github.com/hashicorp/terraform-exec v0.17.3 h1:MX14Kvnka/oWGmIkyuyvL6POx25ZmKrjl github.com/hashicorp/terraform-exec v0.17.3/go.mod h1:+NELG0EqQekJzhvikkeQsOAZpsw0cv/03rbeQJqscAI= github.com/hashicorp/terraform-json v0.15.0 h1:/gIyNtR6SFw6h5yzlbDbACyGvIhKtQi8mTsbkNd79lE= github.com/hashicorp/terraform-json v0.15.0/go.mod h1:+L1RNzjDU5leLFZkHTFTbJXaoqUC6TqXlFgDoOXrtvk= +github.com/hashicorp/terraform-plugin-framework v1.0.0 h1:0Mls4TrMTrDysBUby/UmlbcTOMM+n5JBDyB5k+XkGWg= +github.com/hashicorp/terraform-plugin-framework v1.0.0/go.mod h1:FV97t2BZOARkL7NNlsc/N25c84MyeSSz72uPp7Vq1lg= +github.com/hashicorp/terraform-plugin-framework-validators v0.8.0 h1:hKCuQMjD7W7reAoWn6GLkNwrDNjY9RCBWQZOJxe5LlQ= +github.com/hashicorp/terraform-plugin-framework-validators v0.8.0/go.mod h1:qkrZ542jRiCwwl3ZN/3eTKhGJ4HIBkSxGXnjJoAWtxo= github.com/hashicorp/terraform-plugin-go v0.14.3 h1:nlnJ1GXKdMwsC8g1Nh05tK2wsC3+3BL/DBBxFEki+j0= github.com/hashicorp/terraform-plugin-go v0.14.3/go.mod h1:7ees7DMZ263q8wQ6E4RdIdR6nHHJtrdt4ogX5lPkX1A= github.com/hashicorp/terraform-plugin-log v0.8.0 h1:pX2VQ/TGKu+UU1rCay0OlzosNKe4Nz1pepLXj95oyy0= diff --git a/internal/clients/api_client.go b/internal/clients/api_client.go index ee8c4958c..6e8a32ad5 100644 --- a/internal/clients/api_client.go +++ b/internal/clients/api_client.go @@ -8,6 +8,7 @@ import ( "fmt" "net/http" "os" + "strconv" "strings" "github.com/disaster37/go-kibana-rest/v8" @@ -16,6 +17,9 @@ import ( "github.com/elastic/terraform-provider-elasticstack/internal/models" "github.com/elastic/terraform-provider-elasticstack/internal/utils" "github.com/hashicorp/go-version" + fwdiag "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/logging" @@ -132,6 +136,35 @@ func NewAcceptanceTestingClient() (*ApiClient, error) { const esConnectionKey string = "elasticsearch_connection" +type ElasticSearchConnection struct { + Username types.String `tfsdk:"username"` + Password types.String `tfsdk:"password"` + APIKey types.String `tfsdk:"api_key"` + Endpoints types.List `tfsdk:"endpoints"` + Insecure types.Bool `tfsdk:"insecure"` + CAFile types.String `tfsdk:"ca_file"` + CAData types.String `tfsdk:"ca_data"` + CertFile types.String `tfsdk:"cert_file"` + KeyFile types.String `tfsdk:"key_file"` + CertData types.String `tfsdk:"cert_data"` + KeyData types.String `tfsdk:"key_data"` +} + +func NewFWApiClientFromState(ctx context.Context, state tfsdk.State, defaultClient *ApiClient) (*ApiClient, fwdiag.Diagnostics) { + var es struct { + Connection []*ElasticSearchConnection `tfsdk:"elasticsearch_connection"` + } + diags := state.Get(ctx, &es) + if diags.HasError() { + return nil, diags + } + if len(es.Connection) > 0 { + return NewFWEsApiClient(ctx, es.Connection[0], defaultClient.version, false) + } + + return defaultClient, nil +} + func NewApiClient(d *schema.ResourceData, meta interface{}) (*ApiClient, diag.Diagnostics) { defaultClient := meta.(*ApiClient) @@ -269,6 +302,117 @@ func (a *ApiClient) ClusterID(ctx context.Context) (*string, diag.Diagnostics) { return nil, diags } +func NewFWEsApiClient(ctx context.Context, esConn *ElasticSearchConnection, version string, useEnvAsDefault bool) (*ApiClient, fwdiag.Diagnostics) { + var diags fwdiag.Diagnostics + config := elasticsearch.Config{} + config.Username = getStringValue(esConn.Username, "ELASTICSEARCH_USERNAME", true) + config.Password = getStringValue(esConn.Password, "ELASTICSEARCH_PASSWORD", true) + config.APIKey = getStringValue(esConn.APIKey, "ELASTICSEARCH_API_KEY", true) + + var addrs []string + diags.Append(esConn.Endpoints.ElementsAs(ctx, &addrs, true)...) + if diags.HasError() { + return nil, diags + } + if len(addrs) == 0 && useEnvAsDefault { + if endpoints := os.Getenv("ELASTICSEARCH_ENDPOINTS"); endpoints != "" { + for _, e := range strings.Split(endpoints, ",") { + addrs = append(addrs, strings.TrimSpace(e)) + } + } + } + config.Addresses = addrs + + envInsecure, _ := strconv.ParseBool(os.Getenv("ELASTICSEARCH_INSECURE")) + if esConn.Insecure.ValueBool() || envInsecure { + tlsClientConfig := ensureTLSClientConfig(&config) + tlsClientConfig.InsecureSkipVerify = true + } + + if esConn.CAFile.ValueString() != "" { + caCert, err := os.ReadFile(esConn.CAFile.ValueString()) + if err != nil { + diags.Append(fwdiag.NewErrorDiagnostic( + "Unable to read CA File", + err.Error(), + )) + return nil, diags + } + config.CACert = caCert + } + if esConn.CAData.ValueString() != "" { + config.CACert = []byte(esConn.CAData.ValueString()) + } + + if certFile := esConn.CertFile.ValueString(); certFile != "" { + if keyFile := esConn.KeyFile.ValueString(); keyFile != "" { + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + diags.Append(fwdiag.NewErrorDiagnostic( + "Unable to read certificate or key file", + err.Error(), + )) + return nil, diags + } + tlsClientConfig := ensureTLSClientConfig(&config) + tlsClientConfig.Certificates = []tls.Certificate{cert} + } else { + diags.Append(fwdiag.NewErrorDiagnostic( + "Unable to read key file", + "Path to key file has not been configured or is empty", + )) + return nil, diags + } + } + if certData := esConn.CertData.ValueString(); certData != "" { + if keyData := esConn.KeyData.ValueString(); keyData != "" { + cert, err := tls.X509KeyPair([]byte(certData), []byte(keyData)) + if err != nil { + diags.Append(fwdiag.NewErrorDiagnostic( + "Unable to parse certificate or key", + err.Error(), + )) + return nil, diags + } + tlsClientConfig := ensureTLSClientConfig(&config) + tlsClientConfig.Certificates = []tls.Certificate{cert} + } else { + diags.Append(fwdiag.NewErrorDiagnostic( + "Unable to parse key", + "Key data has not been configured or is empty", + )) + return nil, diags + } + } + + es, err := elasticsearch.NewClient(config) + if err != nil { + diags.Append(fwdiag.NewErrorDiagnostic( + "Unable to create Elasticsearch client", + err.Error(), + )) + return nil, diags + } + if logging.IsDebugOrHigher() { + config.EnableDebugLogger = true + config.Logger = &debugLogger{Name: "elasticsearch"} + } + + return &ApiClient{ + elasticsearch: es, + version: version, + }, diags +} + +func getStringValue(s types.String, envKey string, useEnvAsDefault bool) string { + if s.IsNull() { + if useEnvAsDefault { + return os.Getenv(envKey) + } + } + return s.ValueString() +} + type BaseConfig struct { Username string Password string diff --git a/internal/schema/connection.go b/internal/schema/connection.go index aa20c1e58..a99f597d2 100644 --- a/internal/schema/connection.go +++ b/internal/schema/connection.go @@ -3,9 +3,128 @@ package schema import ( "fmt" + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + fwschema "github.com/hashicorp/terraform-plugin-framework/provider/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) +func GetEsFWConnectionBlock(keyName string, isProviderConfiguration bool) fwschema.Block { + usernamePath := makePathRef(keyName, "username") + passwordPath := makePathRef(keyName, "password") + caFilePath := makePathRef(keyName, "ca_file") + caDataPath := makePathRef(keyName, "ca_data") + certFilePath := makePathRef(keyName, "cert_file") + certDataPath := makePathRef(keyName, "cert_data") + keyFilePath := makePathRef(keyName, "key_file") + keyDataPath := makePathRef(keyName, "key_data") + + usernameValidators := []validator.String{stringvalidator.AlsoRequires(path.MatchRoot(passwordPath))} + passwordValidators := []validator.String{stringvalidator.AlsoRequires(path.MatchRoot(usernamePath))} + + if isProviderConfiguration { + // RequireWith validation isn't compatible when used in conjunction with DefaultFunc + usernameValidators = nil + passwordValidators = nil + } + + return fwschema.ListNestedBlock{ + MarkdownDescription: fmt.Sprintf("Elasticsearch connection configuration block. %s", getDeprecationMessage(isProviderConfiguration)), + DeprecationMessage: getDeprecationMessage(isProviderConfiguration), + NestedObject: fwschema.NestedBlockObject{ + Attributes: map[string]fwschema.Attribute{ + "username": fwschema.StringAttribute{ + MarkdownDescription: "Username to use for API authentication to Elasticsearch.", + Optional: true, + Validators: usernameValidators, + }, + "password": fwschema.StringAttribute{ + MarkdownDescription: "Password to use for API authentication to Elasticsearch.", + Optional: true, + Sensitive: true, + Validators: passwordValidators, + }, + "api_key": fwschema.StringAttribute{ + MarkdownDescription: "API Key to use for authentication to Elasticsearch", + Optional: true, + Sensitive: true, + Validators: []validator.String{ + stringvalidator.ConflictsWith(path.MatchRoot(usernamePath)), + stringvalidator.ConflictsWith(path.MatchRoot(passwordPath)), + }, + }, + "endpoints": fwschema.ListAttribute{ + MarkdownDescription: "A comma-separated list of endpoints where the terraform provider will point to, this must include the http(s) schema and port number.", + Optional: true, + Sensitive: true, + ElementType: types.StringType, + }, + "insecure": fwschema.BoolAttribute{ + MarkdownDescription: "Disable TLS certificate validation", + Optional: true, + }, + "ca_file": fwschema.StringAttribute{ + MarkdownDescription: "Path to a custom Certificate Authority certificate", + Optional: true, + Validators: []validator.String{ + stringvalidator.ConflictsWith(path.MatchRoot(caDataPath)), + }, + }, + "ca_data": fwschema.StringAttribute{ + MarkdownDescription: "PEM-encoded custom Certificate Authority certificate", + Optional: true, + Validators: []validator.String{ + stringvalidator.ConflictsWith(path.MatchRoot(caFilePath)), + }, + }, + "cert_file": fwschema.StringAttribute{ + MarkdownDescription: "Path to a file containing the PEM encoded certificate for client auth", + Optional: true, + Validators: []validator.String{ + stringvalidator.AlsoRequires(path.MatchRoot(keyFilePath)), + stringvalidator.ConflictsWith(path.MatchRoot(certDataPath)), + stringvalidator.ConflictsWith(path.MatchRoot(keyDataPath)), + }, + }, + "key_file": fwschema.StringAttribute{ + MarkdownDescription: "Path to a file containing the PEM encoded private key for client auth", + Optional: true, + Validators: []validator.String{ + stringvalidator.AlsoRequires(path.MatchRoot(certFilePath)), + stringvalidator.ConflictsWith(path.MatchRoot(certDataPath)), + stringvalidator.ConflictsWith(path.MatchRoot(keyDataPath)), + }, + }, + "cert_data": fwschema.StringAttribute{ + MarkdownDescription: "PEM encoded certificate for client auth", + Optional: true, + Validators: []validator.String{ + stringvalidator.AlsoRequires(path.MatchRoot(keyDataPath)), + stringvalidator.ConflictsWith(path.MatchRoot(certFilePath)), + stringvalidator.ConflictsWith(path.MatchRoot(keyFilePath)), + }, + }, + "key_data": fwschema.StringAttribute{ + MarkdownDescription: "PEM encoded private key for client auth", + Optional: true, + Sensitive: true, + Validators: []validator.String{ + stringvalidator.AlsoRequires(path.MatchRoot(certDataPath)), + stringvalidator.ConflictsWith(path.MatchRoot(certFilePath)), + stringvalidator.ConflictsWith(path.MatchRoot(keyFilePath)), + }, + }, + }, + }, + Validators: []validator.List{ + listvalidator.SizeAtMost(1), + }, + } +} + func GetEsConnectionSchema(keyName string, isProviderConfiguration bool) *schema.Schema { usernamePath := makePathRef(keyName, "username") passwordPath := makePathRef(keyName, "password") @@ -20,11 +139,9 @@ func GetEsConnectionSchema(keyName string, isProviderConfiguration bool) *schema passwordRequiredWithValidation := []string{usernamePath} withEnvDefault := func(key string, dv interface{}) schema.SchemaDefaultFunc { return nil } - deprecationMessage := "This property will be removed in a future provider version. Configure the Elasticsearch connection via the provider configuration instead." if isProviderConfiguration { withEnvDefault = func(key string, dv interface{}) schema.SchemaDefaultFunc { return schema.EnvDefaultFunc(key, dv) } - deprecationMessage = "" // RequireWith validation isn't compatible when used in conjunction with DefaultFunc usernameRequiredWithValidation = nil @@ -32,8 +149,8 @@ func GetEsConnectionSchema(keyName string, isProviderConfiguration bool) *schema } return &schema.Schema{ - Description: fmt.Sprintf("Elasticsearch connection configuration block. %s", deprecationMessage), - Deprecated: deprecationMessage, + Description: fmt.Sprintf("Elasticsearch connection configuration block. %s", getDeprecationMessage(isProviderConfiguration)), + Deprecated: getDeprecationMessage(isProviderConfiguration), Type: schema.TypeList, MaxItems: 1, Optional: true, @@ -168,3 +285,10 @@ func GetKibanaConnectionSchema() *schema.Schema { func makePathRef(keyName string, keyValue string) string { return fmt.Sprintf("%s.0.%s", keyName, keyValue) } + +func getDeprecationMessage(isProviderConfiguration bool) string { + if isProviderConfiguration { + return "" + } + return "This property will be removed in a future provider version. Configure the Elasticsearch connection via the provider configuration instead." +} diff --git a/provider/factory.go b/provider/factory.go index cf7404f61..96f346af4 100644 --- a/provider/factory.go +++ b/provider/factory.go @@ -2,6 +2,9 @@ package provider import ( "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/providerserver" "github.com/hashicorp/terraform-plugin-go/tfprotov5" "github.com/hashicorp/terraform-plugin-mux/tf5muxserver" @@ -10,14 +13,16 @@ import ( // ProtoV5ProviderServerFactory returns a muxed terraform-plugin-go protocol v5 provider factory function. func ProtoV5ProviderServerFactory(ctx context.Context, version string) (func() tfprotov5.ProviderServer, error) { sdkv2Provider := New(version) + fwProvider := providerserver.NewProtocol5(NewFrameworkProvider(version)) servers := []func() tfprotov5.ProviderServer{ + fwProvider, sdkv2Provider.GRPCProvider, } muxServer, err := tf5muxserver.NewMuxServer(ctx, servers...) if err != nil { - return nil, err + return nil, fmt.Errorf("initialize mux server: %w", err) } return muxServer.ProviderServer, nil diff --git a/provider/fwprovider.go b/provider/fwprovider.go new file mode 100644 index 000000000..5aeb0df08 --- /dev/null +++ b/provider/fwprovider.go @@ -0,0 +1,61 @@ +package provider + +import ( + "context" + + "github.com/elastic/terraform-provider-elasticstack/internal/clients" + "github.com/elastic/terraform-provider-elasticstack/internal/schema" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/path" + fwprovider "github.com/hashicorp/terraform-plugin-framework/provider" + fwschema "github.com/hashicorp/terraform-plugin-framework/provider/schema" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +type Provider struct { + version string +} + +// NewFrameworkProvider instantiates plugin framework's provider +func NewFrameworkProvider(version string) fwprovider.Provider { + return &Provider{ + version: version, + } +} + +func (p *Provider) Metadata(_ context.Context, _ fwprovider.MetadataRequest, res *fwprovider.MetadataResponse) { + res.TypeName = "elasticstack" + res.Version = p.version +} + +func (p *Provider) Schema(ctx context.Context, req fwprovider.SchemaRequest, res *fwprovider.SchemaResponse) { + res.Schema = fwschema.Schema{ + Blocks: map[string]fwschema.Block{ + esKeyName: schema.GetEsFWConnectionBlock(esKeyName, true), + }, + } +} + +func (p *Provider) Configure(ctx context.Context, req fwprovider.ConfigureRequest, res *fwprovider.ConfigureResponse) { + esConn := []*clients.ElasticSearchConnection{} + diags := req.Config.GetAttribute(ctx, path.Root(esKeyName), &esConn) + res.Diagnostics.Append(diags...) + if res.Diagnostics.HasError() { + return + } + apiClient, diags := clients.NewFWEsApiClient(ctx, esConn[0], p.version, true) + res.Diagnostics.Append(diags...) + if res.Diagnostics.HasError() { + return + } + res.DataSourceData = apiClient + res.ResourceData = apiClient +} + +func (p *Provider) DataSources(ctx context.Context) []func() datasource.DataSource { + return []func() datasource.DataSource{} +} + +func (p *Provider) Resources(ctx context.Context) []func() resource.Resource { + return []func() resource.Resource{} +}