From de65ccfdbe7d984b0a16ee4a9a8cb13ba9685b5d Mon Sep 17 00:00:00 2001 From: Toby Brain Date: Wed, 11 Sep 2024 10:39:58 +1000 Subject: [PATCH] Migrate index resource to plugin framework (#698) * Migrate the index resource to the tf framework * Docs * Mapping replacement * Fixup build * PR feedback --- docs/resources/elasticsearch_index.md | 23 +- .../resource.tf | 7 +- go.mod | 1 + go.sum | 2 + internal/clients/api_client.go | 30 + internal/clients/elasticsearch/index.go | 137 ++- internal/elasticsearch/index/index.go | 1024 ----------------- .../{index_test.go => index/acc_test.go} | 351 +++--- internal/elasticsearch/index/index/create.go | 83 ++ internal/elasticsearch/index/index/delete.go | 45 + .../index/index/mapping_modifier.go | 110 ++ .../index/index/mapping_modifier_test.go | 264 +++++ internal/elasticsearch/index/index/models.go | 642 +++++++++++ .../elasticsearch/index/index/models_test.go | 376 ++++++ internal/elasticsearch/index/index/read.go | 65 ++ .../elasticsearch/index/index/resource.go | 35 + internal/elasticsearch/index/index/schema.go | 517 +++++++++ internal/elasticsearch/index/index/update.go | 155 +++ internal/elasticsearch/index/validation.go | 29 + internal/schema/connection.go | 3 +- internal/utils/customtypes/duration_type.go | 68 ++ .../utils/customtypes/duration_type_test.go | 59 + internal/utils/customtypes/duration_value.go | 142 +++ .../utils/customtypes/duration_value_test.go | 192 ++++ internal/utils/schema.go | 4 +- internal/utils/utils.go | 18 + provider/plugin_framework.go | 4 +- provider/provider.go | 1 - 28 files changed, 3060 insertions(+), 1327 deletions(-) delete mode 100644 internal/elasticsearch/index/index.go rename internal/elasticsearch/index/{index_test.go => index/acc_test.go} (56%) create mode 100644 internal/elasticsearch/index/index/create.go create mode 100644 internal/elasticsearch/index/index/delete.go create mode 100644 internal/elasticsearch/index/index/mapping_modifier.go create mode 100644 internal/elasticsearch/index/index/mapping_modifier_test.go create mode 100644 internal/elasticsearch/index/index/models.go create mode 100644 internal/elasticsearch/index/index/models_test.go create mode 100644 internal/elasticsearch/index/index/read.go create mode 100644 internal/elasticsearch/index/index/resource.go create mode 100644 internal/elasticsearch/index/index/schema.go create mode 100644 internal/elasticsearch/index/index/update.go create mode 100644 internal/utils/customtypes/duration_type.go create mode 100644 internal/utils/customtypes/duration_type_test.go create mode 100644 internal/utils/customtypes/duration_value.go create mode 100644 internal/utils/customtypes/duration_value_test.go diff --git a/docs/resources/elasticsearch_index.md b/docs/resources/elasticsearch_index.md index 89768961f..7ba49d99e 100644 --- a/docs/resources/elasticsearch_index.md +++ b/docs/resources/elasticsearch_index.md @@ -44,10 +44,9 @@ resource "elasticstack_elasticsearch_index" "my_index" { } }) - number_of_shards = 1 - number_of_replicas = 2 - search_idle_after = "20s" - total_shards_per_node = 200 + number_of_shards = 1 + number_of_replicas = 2 + search_idle_after = "20s" } ``` @@ -76,7 +75,7 @@ resource "elasticstack_elasticsearch_index" "my_index" { - `codec` (String) The `default` value compresses stored data with LZ4 compression, but this can be set to `best_compression` which uses DEFLATE for a higher compression ratio. This can be set only on creation. - `default_pipeline` (String) The default ingest node pipeline for this index. Index requests will fail if the default pipeline is set and the pipeline does not exist. - `deletion_protection` (Boolean) Whether to allow Terraform to destroy the index. Unless this field is set to false in Terraform state, a terraform destroy or terraform apply command that deletes the instance will fail. -- `elasticsearch_connection` (Block List, Max: 1, Deprecated) Elasticsearch connection configuration block. This property will be removed in a future provider version. Configure the Elasticsearch connection via the provider configuration instead. (see [below for nested schema](#nestedblock--elasticsearch_connection)) +- `elasticsearch_connection` (Block List, Deprecated) Elasticsearch connection configuration block. (see [below for nested schema](#nestedblock--elasticsearch_connection)) - `final_pipeline` (String) Final ingest pipeline for the index. Indexing requests will fail if the final pipeline is set and the pipeline does not exist. The final pipeline always runs after the request pipeline (if specified) and the default pipeline (if it exists). The special pipeline name _none indicates no ingest pipeline will run. - `gc_deletes` (String) The length of time that a deleted document's version number remains available for further versioned operations. - `highlight_max_analyzed_offset` (Number) The maximum number of characters that will be analyzed for a highlight request. @@ -90,10 +89,10 @@ resource "elasticstack_elasticsearch_index" "my_index" { - `load_fixed_bitset_filters_eagerly` (Boolean) Indicates whether cached filters are pre-loaded for nested queries. This can be set only on creation. - `mapping_coerce` (Boolean) Set index level coercion setting that is applied to all mapping types. - `mappings` (String) Mapping for fields in the index. -If specified, this mapping can include: field names, [field data types](https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-types.html), [mapping parameters](https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-params.html). -**NOTE:** -- Changing datatypes in the existing _mappings_ will force index to be re-created. -- Removing field will be ignored by default same as elasticsearch. You need to recreate the index to remove field completely. + If specified, this mapping can include: field names, [field data types](https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-types.html), [mapping parameters](https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-params.html). + **NOTE:** + - Changing datatypes in the existing _mappings_ will force index to be re-created. + - Removing field will be ignored by default same as elasticsearch. You need to recreate the index to remove field completely. - `master_timeout` (String) Period to wait for a connection to the master node. If no response is received before the timeout expires, the request fails and returns an error. Defaults to `30s`. This value is ignored when running against Serverless projects. - `max_docvalue_fields_search` (Number) The maximum number of `docvalue_fields` that are allowed in a query. - `max_inner_result_window` (Number) The maximum value of `from + size` for inner hits definition and top hits aggregations to this index. @@ -123,7 +122,7 @@ If specified, this mapping can include: field names, [field data types](https:// - `search_slowlog_threshold_query_info` (String) Set the cutoff for shard level slow search logging of slow searches in the query phase, in time units, e.g. `5s` - `search_slowlog_threshold_query_trace` (String) Set the cutoff for shard level slow search logging of slow searches in the query phase, in time units, e.g. `500ms` - `search_slowlog_threshold_query_warn` (String) Set the cutoff for shard level slow search logging of slow searches in the query phase, in time units, e.g. `10s` -- `settings` (Block List, Max: 1, Deprecated) DEPRECATED: Please use dedicated setting field. Configuration options for the index. See, https://www.elastic.co/guide/en/elasticsearch/reference/current/index-modules.html#index-modules-settings. +- `settings` (Block List, Deprecated) DEPRECATED: Please use dedicated setting field. Configuration options for the index. See, https://www.elastic.co/guide/en/elasticsearch/reference/current/index-modules.html#index-modules-settings. **NOTE:** Static index settings (see: https://www.elastic.co/guide/en/elasticsearch/reference/current/index-modules.html#_static_index_settings) can be only set on the index creation and later cannot be removed or updated - _apply_ will return error (see [below for nested schema](#nestedblock--settings)) - `shard_check_on_startup` (String) Whether or not shards should be checked for corruption before opening. When corruption is detected, it will prevent the shard from being opened. Accepts `false`, `true`, `checksum`. - `sort_field` (Set of String) The field to sort shards in this index by. @@ -177,9 +176,9 @@ Optional: ### Nested Schema for `settings` -Required: +Optional: -- `setting` (Block Set, Min: 1) Defines the setting for the index. (see [below for nested schema](#nestedblock--settings--setting)) +- `setting` (Block Set) Defines the setting for the index. (see [below for nested schema](#nestedblock--settings--setting)) ### Nested Schema for `settings.setting` diff --git a/examples/resources/elasticstack_elasticsearch_index/resource.tf b/examples/resources/elasticstack_elasticsearch_index/resource.tf index 19198ffb6..3b5801672 100644 --- a/examples/resources/elasticstack_elasticsearch_index/resource.tf +++ b/examples/resources/elasticstack_elasticsearch_index/resource.tf @@ -29,8 +29,7 @@ resource "elasticstack_elasticsearch_index" "my_index" { } }) - number_of_shards = 1 - number_of_replicas = 2 - search_idle_after = "20s" - total_shards_per_node = 200 + number_of_shards = 1 + number_of_replicas = 2 + search_idle_after = "20s" } diff --git a/go.mod b/go.mod index e784ad91f..6e8157bfd 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ toolchain go1.23.1 require ( github.com/disaster37/go-kibana-rest/v8 v8.5.0 github.com/elastic/go-elasticsearch/v7 v7.17.10 + github.com/google/gofuzz v1.2.0 github.com/google/uuid v1.6.0 github.com/hashicorp/go-cty v1.4.1-0.20200723130312-85980079f637 github.com/hashicorp/go-version v1.7.0 diff --git a/go.sum b/go.sum index d3f6a6635..bed059035 100644 --- a/go.sum +++ b/go.sum @@ -51,6 +51,8 @@ github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= diff --git a/internal/clients/api_client.go b/internal/clients/api_client.go index 9f929703a..9e990a1fb 100644 --- a/internal/clients/api_client.go +++ b/internal/clients/api_client.go @@ -20,6 +20,7 @@ import ( "github.com/elastic/terraform-provider-elasticstack/internal/utils" "github.com/hashicorp/go-version" fwdiags "github.com/hashicorp/terraform-plugin-framework/diag" + "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" @@ -156,6 +157,35 @@ func ConvertProviderData(providerData any) (*ApiClient, fwdiags.Diagnostics) { return client, diags } +func MaybeNewApiClientFromFrameworkResource(ctx context.Context, esConnList types.List, defaultClient *ApiClient) (*ApiClient, fwdiags.Diagnostics) { + var esConns []config.ElasticsearchConnection + if diags := esConnList.ElementsAs(ctx, &esConns, true); diags.HasError() { + return nil, diags + } + + if len(esConns) == 0 { + return defaultClient, nil + } + + cfg, diags := config.NewFromFramework(ctx, config.ProviderConfiguration{Elasticsearch: esConns}, defaultClient.version) + if diags.HasError() { + return nil, diags + } + + esClient, err := buildEsClient(cfg) + if err != nil { + return nil, fwdiags.Diagnostics{fwdiags.NewErrorDiagnostic(err.Error(), err.Error())} + } + + return &ApiClient{ + elasticsearch: esClient, + elasticsearchClusterInfo: defaultClient.elasticsearchClusterInfo, + kibana: defaultClient.kibana, + fleet: defaultClient.fleet, + version: defaultClient.version, + }, diags +} + func NewApiClientFromSDKResource(d *schema.ResourceData, meta interface{}) (*ApiClient, diag.Diagnostics) { defaultClient := meta.(*ApiClient) version := defaultClient.version diff --git a/internal/clients/elasticsearch/index.go b/internal/clients/elasticsearch/index.go index b9c3054fb..4481686c2 100644 --- a/internal/clients/elasticsearch/index.go +++ b/internal/clients/elasticsearch/index.go @@ -12,6 +12,7 @@ import ( "github.com/elastic/terraform-provider-elasticstack/internal/clients" "github.com/elastic/terraform-provider-elasticstack/internal/models" "github.com/elastic/terraform-provider-elasticstack/internal/utils" + fwdiags "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" ) @@ -245,16 +246,19 @@ func DeleteIndexTemplate(ctx context.Context, apiClient *clients.ApiClient, temp return diags } -func PutIndex(ctx context.Context, apiClient *clients.ApiClient, index *models.Index, params *models.PutIndexParams) diag.Diagnostics { - var diags diag.Diagnostics +func PutIndex(ctx context.Context, apiClient *clients.ApiClient, index *models.Index, params *models.PutIndexParams) fwdiags.Diagnostics { indexBytes, err := json.Marshal(index) if err != nil { - return diag.FromErr(err) + return fwdiags.Diagnostics{ + fwdiags.NewErrorDiagnostic(err.Error(), err.Error()), + } } esClient, err := apiClient.GetESClient() if err != nil { - return diag.FromErr(err) + return fwdiags.Diagnostics{ + fwdiags.NewErrorDiagnostic(err.Error(), err.Error()), + } } opts := []func(*esapi.IndicesCreateRequest){ @@ -272,45 +276,46 @@ func PutIndex(ctx context.Context, apiClient *clients.ApiClient, index *models.I opts..., ) if err != nil { - return diag.FromErr(err) + return fwdiags.Diagnostics{ + fwdiags.NewErrorDiagnostic(err.Error(), err.Error()), + } } defer res.Body.Close() - if diags := utils.CheckError(res, fmt.Sprintf("Unable to create index: %s", index.Name)); diags.HasError() { - return diags - } - return diags + diags := utils.CheckError(res, fmt.Sprintf("Unable to create index: %s", index.Name)) + return utils.FrameworkDiagsFromSDK(diags) } -func DeleteIndex(ctx context.Context, apiClient *clients.ApiClient, name string) diag.Diagnostics { - var diags diag.Diagnostics - +func DeleteIndex(ctx context.Context, apiClient *clients.ApiClient, name string) fwdiags.Diagnostics { esClient, err := apiClient.GetESClient() if err != nil { - return diag.FromErr(err) + return fwdiags.Diagnostics{ + fwdiags.NewErrorDiagnostic(err.Error(), err.Error()), + } } res, err := esClient.Indices.Delete([]string{name}, esClient.Indices.Delete.WithContext(ctx)) if err != nil { - return diag.FromErr(err) + return fwdiags.Diagnostics{ + fwdiags.NewErrorDiagnostic(err.Error(), err.Error()), + } } defer res.Body.Close() - if diags := utils.CheckError(res, fmt.Sprintf("Unable to delete the index: %s", name)); diags.HasError() { - return diags - } - - return diags + diags := utils.CheckError(res, fmt.Sprintf("Unable to delete the index: %s", name)) + return utils.FrameworkDiagsFromSDK(diags) } -func GetIndex(ctx context.Context, apiClient *clients.ApiClient, name string) (*models.Index, diag.Diagnostics) { - var diags diag.Diagnostics - +func GetIndex(ctx context.Context, apiClient *clients.ApiClient, name string) (*models.Index, fwdiags.Diagnostics) { esClient, err := apiClient.GetESClient() if err != nil { - return nil, diag.FromErr(err) + return nil, fwdiags.Diagnostics{ + fwdiags.NewErrorDiagnostic(err.Error(), err.Error()), + } } req := esClient.Indices.Get.WithFlatSettings(true) res, err := esClient.Indices.Get([]string{name}, req, esClient.Indices.Get.WithContext(ctx)) if err != nil { - return nil, diag.FromErr(err) + return nil, fwdiags.Diagnostics{ + fwdiags.NewErrorDiagnostic(err.Error(), err.Error()), + } } defer res.Body.Close() // if there is no index found, return the empty struct, which should force the creation of the index @@ -319,94 +324,104 @@ func GetIndex(ctx context.Context, apiClient *clients.ApiClient, name string) (* } if diags := utils.CheckError(res, fmt.Sprintf("Unable to get requested index: %s", name)); diags.HasError() { - return nil, diags + return nil, utils.FrameworkDiagsFromSDK(diags) } indices := make(map[string]models.Index) if err := json.NewDecoder(res.Body).Decode(&indices); err != nil { - return nil, diag.FromErr(err) + return nil, fwdiags.Diagnostics{ + fwdiags.NewErrorDiagnostic(err.Error(), err.Error()), + } } index := indices[name] - return &index, diags + return &index, nil } -func DeleteIndexAlias(ctx context.Context, apiClient *clients.ApiClient, index string, aliases []string) diag.Diagnostics { - var diags diag.Diagnostics +func DeleteIndexAlias(ctx context.Context, apiClient *clients.ApiClient, index string, aliases []string) fwdiags.Diagnostics { esClient, err := apiClient.GetESClient() if err != nil { - return diag.FromErr(err) + return fwdiags.Diagnostics{ + fwdiags.NewErrorDiagnostic(err.Error(), err.Error()), + } } res, err := esClient.Indices.DeleteAlias([]string{index}, aliases, esClient.Indices.DeleteAlias.WithContext(ctx)) if err != nil { - return diag.FromErr(err) + return fwdiags.Diagnostics{ + fwdiags.NewErrorDiagnostic(err.Error(), err.Error()), + } } defer res.Body.Close() - if diags := utils.CheckError(res, fmt.Sprintf("Unable to delete aliases '%v' for index '%s'", index, aliases)); diags.HasError() { - return diags - } - return diags + diags := utils.CheckError(res, fmt.Sprintf("Unable to delete aliases '%v' for index '%s'", index, aliases)) + return utils.FrameworkDiagsFromSDK(diags) } -func UpdateIndexAlias(ctx context.Context, apiClient *clients.ApiClient, index string, alias *models.IndexAlias) diag.Diagnostics { - var diags diag.Diagnostics +func UpdateIndexAlias(ctx context.Context, apiClient *clients.ApiClient, index string, alias *models.IndexAlias) fwdiags.Diagnostics { aliasBytes, err := json.Marshal(alias) if err != nil { - return diag.FromErr(err) + return fwdiags.Diagnostics{ + fwdiags.NewErrorDiagnostic(err.Error(), err.Error()), + } } esClient, err := apiClient.GetESClient() if err != nil { - return diag.FromErr(err) + return fwdiags.Diagnostics{ + fwdiags.NewErrorDiagnostic(err.Error(), err.Error()), + } } req := esClient.Indices.PutAlias.WithBody(bytes.NewReader(aliasBytes)) res, err := esClient.Indices.PutAlias([]string{index}, alias.Name, req, esClient.Indices.PutAlias.WithContext(ctx)) if err != nil { - return diag.FromErr(err) + return fwdiags.Diagnostics{ + fwdiags.NewErrorDiagnostic(err.Error(), err.Error()), + } } defer res.Body.Close() - if diags := utils.CheckError(res, fmt.Sprintf("Unable to update alias '%v' for index '%s'", index, alias.Name)); diags.HasError() { - return diags - } - return diags + diags := utils.CheckError(res, fmt.Sprintf("Unable to update alias '%v' for index '%s'", index, alias.Name)) + return utils.FrameworkDiagsFromSDK(diags) } -func UpdateIndexSettings(ctx context.Context, apiClient *clients.ApiClient, index string, settings map[string]interface{}) diag.Diagnostics { - var diags diag.Diagnostics +func UpdateIndexSettings(ctx context.Context, apiClient *clients.ApiClient, index string, settings map[string]interface{}) fwdiags.Diagnostics { settingsBytes, err := json.Marshal(settings) if err != nil { - return diag.FromErr(err) + return fwdiags.Diagnostics{ + fwdiags.NewErrorDiagnostic(err.Error(), err.Error()), + } } esClient, err := apiClient.GetESClient() if err != nil { - return diag.FromErr(err) + return fwdiags.Diagnostics{ + fwdiags.NewErrorDiagnostic(err.Error(), err.Error()), + } } req := esClient.Indices.PutSettings.WithIndex(index) res, err := esClient.Indices.PutSettings(bytes.NewReader(settingsBytes), req, esClient.Indices.PutSettings.WithContext(ctx)) if err != nil { - return diag.FromErr(err) + return fwdiags.Diagnostics{ + fwdiags.NewErrorDiagnostic(err.Error(), err.Error()), + } } defer res.Body.Close() - if diags := utils.CheckError(res, "Unable to update index settings"); diags.HasError() { - return diags - } - return diags + diags := utils.CheckError(res, "Unable to update index settings") + return utils.FrameworkDiagsFromSDK(diags) } -func UpdateIndexMappings(ctx context.Context, apiClient *clients.ApiClient, index, mappings string) diag.Diagnostics { - var diags diag.Diagnostics +func UpdateIndexMappings(ctx context.Context, apiClient *clients.ApiClient, index, mappings string) fwdiags.Diagnostics { esClient, err := apiClient.GetESClient() if err != nil { - return diag.FromErr(err) + return fwdiags.Diagnostics{ + fwdiags.NewErrorDiagnostic(err.Error(), err.Error()), + } } req := esClient.Indices.PutMapping.WithIndex(index) res, err := esClient.Indices.PutMapping(strings.NewReader(mappings), req, esClient.Indices.PutMapping.WithContext(ctx)) if err != nil { - return diag.FromErr(err) + return fwdiags.Diagnostics{ + fwdiags.NewErrorDiagnostic(err.Error(), err.Error()), + } } defer res.Body.Close() - if diags := utils.CheckError(res, "Unable to update index mappings"); diags.HasError() { - return diags - } - return diags + diags := utils.CheckError(res, "Unable to update index mappings") + return utils.FrameworkDiagsFromSDK(diags) } func PutDataStream(ctx context.Context, apiClient *clients.ApiClient, dataStreamName string) diag.Diagnostics { diff --git a/internal/elasticsearch/index/index.go b/internal/elasticsearch/index/index.go deleted file mode 100644 index 27591e4c2..000000000 --- a/internal/elasticsearch/index/index.go +++ /dev/null @@ -1,1024 +0,0 @@ -package index - -import ( - "context" - "encoding/json" - "fmt" - "reflect" - "regexp" - "strconv" - "strings" - "time" - - "github.com/elastic/terraform-provider-elasticstack/internal/clients" - "github.com/elastic/terraform-provider-elasticstack/internal/clients/elasticsearch" - "github.com/elastic/terraform-provider-elasticstack/internal/models" - "github.com/elastic/terraform-provider-elasticstack/internal/utils" - "github.com/hashicorp/go-version" - "github.com/hashicorp/terraform-plugin-log/tflog" - "github.com/hashicorp/terraform-plugin-sdk/v2/diag" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/customdiff" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" -) - -var ( - staticSettingsKeys = map[string]schema.ValueType{ - "number_of_shards": schema.TypeInt, - "number_of_routing_shards": schema.TypeInt, - "codec": schema.TypeString, - "routing_partition_size": schema.TypeInt, - "load_fixed_bitset_filters_eagerly": schema.TypeBool, - "shard.check_on_startup": schema.TypeString, - "sort.field": schema.TypeSet, - "sort.order": schema.TypeSet, - "mapping.coerce": schema.TypeBool, - } - dynamicsSettingsKeys = map[string]schema.ValueType{ - "number_of_replicas": schema.TypeInt, - "auto_expand_replicas": schema.TypeString, - "refresh_interval": schema.TypeString, - "search.idle.after": schema.TypeString, - "max_result_window": schema.TypeInt, - "max_inner_result_window": schema.TypeInt, - "max_rescore_window": schema.TypeInt, - "max_docvalue_fields_search": schema.TypeInt, - "max_script_fields": schema.TypeInt, - "max_ngram_diff": schema.TypeInt, - "max_shingle_diff": schema.TypeInt, - "blocks.read_only": schema.TypeBool, - "blocks.read_only_allow_delete": schema.TypeBool, - "blocks.read": schema.TypeBool, - "blocks.write": schema.TypeBool, - "blocks.metadata": schema.TypeBool, - "max_refresh_listeners": schema.TypeInt, - "analyze.max_token_count": schema.TypeInt, - "highlight.max_analyzed_offset": schema.TypeInt, - "max_terms_count": schema.TypeInt, - "max_regex_length": schema.TypeInt, - "query.default_field": schema.TypeSet, - "routing.allocation.enable": schema.TypeString, - "routing.rebalance.enable": schema.TypeString, - "gc_deletes": schema.TypeString, - "default_pipeline": schema.TypeString, - "final_pipeline": schema.TypeString, - "unassigned.node_left.delayed_timeout": schema.TypeString, - "search.slowlog.threshold.query.warn": schema.TypeString, - "search.slowlog.threshold.query.info": schema.TypeString, - "search.slowlog.threshold.query.debug": schema.TypeString, - "search.slowlog.threshold.query.trace": schema.TypeString, - "search.slowlog.threshold.fetch.warn": schema.TypeString, - "search.slowlog.threshold.fetch.info": schema.TypeString, - "search.slowlog.threshold.fetch.debug": schema.TypeString, - "search.slowlog.threshold.fetch.trace": schema.TypeString, - "search.slowlog.level": schema.TypeString, - "indexing.slowlog.threshold.index.warn": schema.TypeString, - "indexing.slowlog.threshold.index.info": schema.TypeString, - "indexing.slowlog.threshold.index.debug": schema.TypeString, - "indexing.slowlog.threshold.index.trace": schema.TypeString, - "indexing.slowlog.level": schema.TypeString, - "indexing.slowlog.source": schema.TypeString, - } - allSettingsKeys = map[string]schema.ValueType{} -) - -var includeTypeNameMinUnsupportedVersion = version.Must(version.NewVersion("8.0.0")) - -func init() { - for k, v := range staticSettingsKeys { - allSettingsKeys[k] = v - } - for k, v := range dynamicsSettingsKeys { - allSettingsKeys[k] = v - } -} - -func ResourceIndex() *schema.Resource { - indexSchema := map[string]*schema.Schema{ - "id": { - Description: "Internal identifier of the resource", - Type: schema.TypeString, - Computed: true, - }, - "name": { - Description: "Name of the index you wish to create.", - Type: schema.TypeString, - Required: true, - ForceNew: true, - ValidateFunc: validation.All( - validation.StringLenBetween(1, 255), - validation.StringNotInSlice([]string{".", ".."}, true), - validation.StringMatch(regexp.MustCompile(`^[^-_+]`), "cannot start with -, _, +"), - validation.StringMatch(regexp.MustCompile(`^[a-z0-9!$%&'()+.;=@[\]^{}~_-]+$`), "must contain lower case alphanumeric characters and selected punctuation, see: https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-create-index.html#indices-create-api-path-params"), - ), - }, - // Static settings that can only be set on creation - "number_of_shards": { - Type: schema.TypeInt, - Description: "Number of shards for the index. This can be set only on creation.", - ForceNew: true, - Optional: true, - }, - "number_of_routing_shards": { - Type: schema.TypeInt, - Description: "Value used with number_of_shards to route documents to a primary shard. This can be set only on creation.", - ForceNew: true, - Optional: true, - }, - "codec": { - Type: schema.TypeString, - Description: "The `default` value compresses stored data with LZ4 compression, but this can be set to `best_compression` which uses DEFLATE for a higher compression ratio. This can be set only on creation.", - ForceNew: true, - Optional: true, - ValidateFunc: validation.StringInSlice([]string{"best_compression"}, false), - }, - "routing_partition_size": { - Type: schema.TypeInt, - Description: "The number of shards a custom routing value can go to. This can be set only on creation.", - ForceNew: true, - Optional: true, - }, - "load_fixed_bitset_filters_eagerly": { - Type: schema.TypeBool, - Description: "Indicates whether cached filters are pre-loaded for nested queries. This can be set only on creation.", - ForceNew: true, - Optional: true, - }, - "shard_check_on_startup": { - Type: schema.TypeString, - Description: "Whether or not shards should be checked for corruption before opening. When corruption is detected, it will prevent the shard from being opened. Accepts `false`, `true`, `checksum`.", - ForceNew: true, - Optional: true, - ValidateFunc: validation.StringInSlice([]string{"false", "true", "checksum"}, false), - }, - "sort_field": { - Type: schema.TypeSet, - Elem: &schema.Schema{Type: schema.TypeString}, - Description: "The field to sort shards in this index by.", - ForceNew: true, - Optional: true, - }, - // sort_order can't be set type since it can have dup strings like ["asc", "asc"] - "sort_order": { - Type: schema.TypeList, - Elem: &schema.Schema{Type: schema.TypeString}, - Description: "The direction to sort shards in. Accepts `asc`, `desc`.", - ForceNew: true, - Optional: true, - }, - "mapping_coerce": { - Type: schema.TypeBool, - Description: "Set index level coercion setting that is applied to all mapping types.", - ForceNew: true, - Optional: true, - }, - // Dynamic settings that can be changed at runtime - "number_of_replicas": { - Type: schema.TypeInt, - Description: "Number of shard replicas.", - Optional: true, - Computed: true, - }, - "auto_expand_replicas": { - Type: schema.TypeString, - Description: "Set the number of replicas to the node count in the cluster. Set to a dash delimited lower and upper bound (e.g. 0-5) or use all for the upper bound (e.g. 0-all)", - Optional: true, - }, - "search_idle_after": { - Type: schema.TypeString, - Description: "How long a shard can not receive a search or get request until it’s considered search idle.", - Optional: true, - }, - "refresh_interval": { - Type: schema.TypeString, - Description: "How often to perform a refresh operation, which makes recent changes to the index visible to search. Can be set to `-1` to disable refresh.", - Optional: true, - }, - "max_result_window": { - Type: schema.TypeInt, - Description: "The maximum value of `from + size` for searches to this index.", - Optional: true, - }, - "max_inner_result_window": { - Type: schema.TypeInt, - Description: "The maximum value of `from + size` for inner hits definition and top hits aggregations to this index.", - Optional: true, - }, - "max_rescore_window": { - Type: schema.TypeInt, - Description: "The maximum value of `window_size` for `rescore` requests in searches of this index.", - Optional: true, - }, - "max_docvalue_fields_search": { - Type: schema.TypeInt, - Description: "The maximum number of `docvalue_fields` that are allowed in a query.", - Optional: true, - }, - "max_script_fields": { - Type: schema.TypeInt, - Description: "The maximum number of `script_fields` that are allowed in a query.", - Optional: true, - }, - "max_ngram_diff": { - Type: schema.TypeInt, - Description: "The maximum allowed difference between min_gram and max_gram for NGramTokenizer and NGramTokenFilter.", - Optional: true, - }, - "max_shingle_diff": { - Type: schema.TypeInt, - Description: "The maximum allowed difference between max_shingle_size and min_shingle_size for ShingleTokenFilter.", - Optional: true, - }, - "max_refresh_listeners": { - Type: schema.TypeInt, - Description: "Maximum number of refresh listeners available on each shard of the index.", - Optional: true, - }, - "analyze_max_token_count": { - Type: schema.TypeInt, - Description: "The maximum number of tokens that can be produced using _analyze API.", - Optional: true, - }, - "highlight_max_analyzed_offset": { - Type: schema.TypeInt, - Description: "The maximum number of characters that will be analyzed for a highlight request.", - Optional: true, - }, - "max_terms_count": { - Type: schema.TypeInt, - Description: "The maximum number of terms that can be used in Terms Query.", - Optional: true, - }, - "max_regex_length": { - Type: schema.TypeInt, - Description: "The maximum length of regex that can be used in Regexp Query.", - Optional: true, - }, - "query_default_field": { - Type: schema.TypeSet, - Elem: &schema.Schema{Type: schema.TypeString}, - Description: "Wildcard (*) patterns matching one or more fields. Defaults to '*', which matches all fields eligible for term-level queries, excluding metadata fields.", - Optional: true, - }, - "routing_allocation_enable": { - Type: schema.TypeString, - Description: "Controls shard allocation for this index. It can be set to: `all` , `primaries` , `new_primaries` , `none`.", - Optional: true, - ValidateFunc: validation.StringInSlice([]string{"all", "primaries", "new_primaries", "none"}, false), - }, - "routing_rebalance_enable": { - Type: schema.TypeString, - Description: "Enables shard rebalancing for this index. It can be set to: `all`, `primaries` , `replicas` , `none`.", - Optional: true, - ValidateFunc: validation.StringInSlice([]string{"all", "primaries", "replicas", "none"}, false), - }, - "gc_deletes": { - Type: schema.TypeString, - Description: "The length of time that a deleted document's version number remains available for further versioned operations.", - Optional: true, - }, - "blocks_read_only": { - Type: schema.TypeBool, - Description: "Set to `true` to make the index and index metadata read only, `false` to allow writes and metadata changes.", - Optional: true, - }, - "blocks_read_only_allow_delete": { - Type: schema.TypeBool, - Description: "Identical to `index.blocks.read_only` but allows deleting the index to free up resources.", - Optional: true, - }, - "blocks_read": { - Type: schema.TypeBool, - Description: "Set to `true` to disable read operations against the index.", - Optional: true, - }, - "blocks_write": { - Type: schema.TypeBool, - Description: "Set to `true` to disable data write operations against the index. This setting does not affect metadata.", - Optional: true, - }, - "blocks_metadata": { - Type: schema.TypeBool, - Description: "Set to `true` to disable index metadata reads and writes.", - Optional: true, - }, - "default_pipeline": { - Type: schema.TypeString, - Description: "The default ingest node pipeline for this index. Index requests will fail if the default pipeline is set and the pipeline does not exist.", - Optional: true, - }, - "final_pipeline": { - Type: schema.TypeString, - Description: "Final ingest pipeline for the index. Indexing requests will fail if the final pipeline is set and the pipeline does not exist. The final pipeline always runs after the request pipeline (if specified) and the default pipeline (if it exists). The special pipeline name _none indicates no ingest pipeline will run.", - Optional: true, - }, - "unassigned_node_left_delayed_timeout": { - - Type: schema.TypeString, - Description: "Time to delay the allocation of replica shards which become unassigned because a node has left, in time units, e.g. `10s`", - Optional: true, - }, - "search_slowlog_threshold_query_warn": { - Type: schema.TypeString, - Description: "Set the cutoff for shard level slow search logging of slow searches in the query phase, in time units, e.g. `10s`", - Optional: true, - }, - "search_slowlog_threshold_query_info": { - Type: schema.TypeString, - Description: "Set the cutoff for shard level slow search logging of slow searches in the query phase, in time units, e.g. `5s`", - Optional: true, - }, - "search_slowlog_threshold_query_debug": { - Type: schema.TypeString, - Description: "Set the cutoff for shard level slow search logging of slow searches in the query phase, in time units, e.g. `2s`", - Optional: true, - }, - "search_slowlog_threshold_query_trace": { - Type: schema.TypeString, - Description: "Set the cutoff for shard level slow search logging of slow searches in the query phase, in time units, e.g. `500ms`", - Optional: true, - }, - "search_slowlog_threshold_fetch_warn": { - Type: schema.TypeString, - Description: "Set the cutoff for shard level slow search logging of slow searches in the fetch phase, in time units, e.g. `10s`", - Optional: true, - }, - "search_slowlog_threshold_fetch_info": { - Type: schema.TypeString, - Description: "Set the cutoff for shard level slow search logging of slow searches in the fetch phase, in time units, e.g. `5s`", - Optional: true, - }, - "search_slowlog_threshold_fetch_debug": { - Type: schema.TypeString, - Description: "Set the cutoff for shard level slow search logging of slow searches in the fetch phase, in time units, e.g. `2s`", - Optional: true, - }, - "search_slowlog_threshold_fetch_trace": { - Type: schema.TypeString, - Description: "Set the cutoff for shard level slow search logging of slow searches in the fetch phase, in time units, e.g. `500ms`", - Optional: true, - }, - "search_slowlog_level": { - Type: schema.TypeString, - Description: "Set which logging level to use for the search slow log, can be: `warn`, `info`, `debug`, `trace`", - Optional: true, - ValidateFunc: validation.StringInSlice([]string{"warn", "info", "debug", "trace"}, false), - }, - "indexing_slowlog_threshold_index_warn": { - Type: schema.TypeString, - Description: "Set the cutoff for shard level slow search logging of slow searches for indexing queries, in time units, e.g. `10s`", - Optional: true, - }, - "indexing_slowlog_threshold_index_info": { - Type: schema.TypeString, - Description: "Set the cutoff for shard level slow search logging of slow searches for indexing queries, in time units, e.g. `5s`", - Optional: true, - }, - "indexing_slowlog_threshold_index_debug": { - Type: schema.TypeString, - Description: "Set the cutoff for shard level slow search logging of slow searches for indexing queries, in time units, e.g. `2s`", - Optional: true, - }, - "indexing_slowlog_threshold_index_trace": { - Type: schema.TypeString, - Description: "Set the cutoff for shard level slow search logging of slow searches for indexing queries, in time units, e.g. `500ms`", - Optional: true, - }, - "indexing_slowlog_level": { - Type: schema.TypeString, - Description: "Set which logging level to use for the search slow log, can be: `warn`, `info`, `debug`, `trace`", - Optional: true, - ValidateFunc: validation.StringInSlice([]string{"warn", "info", "debug", "trace"}, false), - }, - "indexing_slowlog_source": { - Type: schema.TypeString, - Description: "Set the number of characters of the `_source` to include in the slowlog lines, `false` or `0` will skip logging the source entirely and setting it to `true` will log the entire source regardless of size. The original `_source` is reformatted by default to make sure that it fits on a single log line.", - Optional: true, - }, - // To change analyzer setting, the index must be closed, updated, and then reopened but it can't be handled in terraform. - // We raise error when they are tried to be updated instead of setting ForceNew not to have unexpected deletion. - "analysis_analyzer": { - Type: schema.TypeString, - Description: "A JSON string describing the analyzers applied to the index.", - Optional: true, - ValidateFunc: validation.StringIsJSON, - }, - "analysis_tokenizer": { - Type: schema.TypeString, - Description: "A JSON string describing the tokenizers applied to the index.", - Optional: true, - ValidateFunc: validation.StringIsJSON, - }, - "analysis_char_filter": { - Type: schema.TypeString, - Description: "A JSON string describing the char_filters applied to the index.", - Optional: true, - ValidateFunc: validation.StringIsJSON, - }, - "analysis_filter": { - Type: schema.TypeString, - Description: "A JSON string describing the filters applied to the index.", - Optional: true, - ValidateFunc: validation.StringIsJSON, - }, - "analysis_normalizer": { - Type: schema.TypeString, - Description: "A JSON string describing the normalizers applied to the index.", - Optional: true, - ValidateFunc: validation.StringIsJSON, - }, - "alias": { - Description: "Aliases for the index.", - Type: schema.TypeSet, - Optional: true, - Elem: &schema.Resource{ - Schema: map[string]*schema.Schema{ - "name": { - Description: "Index alias name.", - Type: schema.TypeString, - Required: true, - }, - "filter": { - Description: "Query used to limit documents the alias can access.", - Type: schema.TypeString, - Optional: true, - Default: "", - DiffSuppressFunc: utils.DiffJsonSuppress, - ValidateFunc: validation.StringIsJSON, - }, - "index_routing": { - Description: "Value used to route indexing operations to a specific shard. If specified, this overwrites the `routing` value for indexing operations.", - Type: schema.TypeString, - Optional: true, - Default: "", - }, - "is_hidden": { - Description: "If true, the alias is hidden.", - Type: schema.TypeBool, - Optional: true, - Default: false, - }, - "is_write_index": { - Description: "If true, the index is the write index for the alias.", - Type: schema.TypeBool, - Optional: true, - Default: false, - }, - "routing": { - Description: "Value used to route indexing and search operations to a specific shard.", - Type: schema.TypeString, - Optional: true, - Default: "", - }, - "search_routing": { - Description: "Value used to route search operations to a specific shard. If specified, this overwrites the routing value for search operations.", - Type: schema.TypeString, - Optional: true, - Default: "", - }, - }, - }, - }, - "mappings": { - Description: `Mapping for fields in the index. -If specified, this mapping can include: field names, [field data types](https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-types.html), [mapping parameters](https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-params.html). -**NOTE:** -- Changing datatypes in the existing _mappings_ will force index to be re-created. -- Removing field will be ignored by default same as elasticsearch. You need to recreate the index to remove field completely. -`, - Type: schema.TypeString, - Optional: true, - DiffSuppressFunc: utils.DiffJsonSuppress, - ValidateFunc: validation.All( - validation.StringIsJSON, stringIsJSONObject, - ), - Default: "{}", - }, - // Deprecated: individual setting field should be used instead - "settings": { - Description: `DEPRECATED: Please use dedicated setting field. Configuration options for the index. See, https://www.elastic.co/guide/en/elasticsearch/reference/current/index-modules.html#index-modules-settings. -**NOTE:** Static index settings (see: https://www.elastic.co/guide/en/elasticsearch/reference/current/index-modules.html#_static_index_settings) can be only set on the index creation and later cannot be removed or updated - _apply_ will return error`, - Type: schema.TypeList, - MaxItems: 1, - Optional: true, - Deprecated: "Using settings makes it easier to misconfigure. Use dedicated field for the each setting instead.", - Elem: &schema.Resource{ - Schema: map[string]*schema.Schema{ - "setting": { - Description: "Defines the setting for the index.", - Type: schema.TypeSet, - Required: true, - MinItems: 1, - Elem: &schema.Resource{ - Schema: map[string]*schema.Schema{ - "name": { - Description: "The name of the setting to set and track.", - Type: schema.TypeString, - Required: true, - }, - "value": { - Description: "The value of the setting to set and track.", - Type: schema.TypeString, - Required: true, - }, - }, - }, - }, - }, - }, - }, - "settings_raw": { - Description: "All raw settings fetched from the cluster.", - Type: schema.TypeString, - Computed: true, - }, - "deletion_protection": { - Type: schema.TypeBool, - Optional: true, - Default: true, - Description: "Whether to allow Terraform to destroy the index. Unless this field is set to false in Terraform state, a terraform destroy or terraform apply command that deletes the instance will fail.", - }, - "include_type_name": { - Type: schema.TypeBool, - Description: "If true, a mapping type is expected in the body of mappings. Defaults to false. Supported for Elasticsearch 7.x.", - Optional: true, - Default: false, - }, - "wait_for_active_shards": { - Type: schema.TypeString, - Description: "The number of shard copies that must be active before proceeding with the operation. Set to `all` or any positive integer up to the total number of shards in the index (number_of_replicas+1). Default: `1`, the primary shard. This value is ignored when running against Serverless projects.", - Optional: true, - Default: "1", - }, - "master_timeout": { - Type: schema.TypeString, - Description: "Period to wait for a connection to the master node. If no response is received before the timeout expires, the request fails and returns an error. Defaults to `30s`. This value is ignored when running against Serverless projects.", - Optional: true, - Default: "30s", - ValidateFunc: utils.StringIsDuration, - }, - "timeout": { - Type: schema.TypeString, - Description: "Period to wait for a response. If no response is received before the timeout expires, the request fails and returns an error. Defaults to `30s`.", - Optional: true, - Default: "30s", - ValidateFunc: utils.StringIsDuration, - }, - } - - utils.AddConnectionSchema(indexSchema) - - return &schema.Resource{ - Description: "Creates Elasticsearch indices. See: https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-create-index.html", - - CreateContext: resourceIndexCreate, - UpdateContext: resourceIndexUpdate, - ReadContext: resourceIndexRead, - DeleteContext: resourceIndexDelete, - - Importer: &schema.ResourceImporter{ - StateContext: func(ctx context.Context, d *schema.ResourceData, m interface{}) ([]*schema.ResourceData, error) { - // first populate what we can with Read - diags := resourceIndexRead(ctx, d, m) - if diags.HasError() { - return nil, fmt.Errorf("unable to import requested index") - } - - client, diags := clients.NewApiClientFromSDKResource(d, m) - if diags.HasError() { - return nil, fmt.Errorf("Unabled to create API client %v", diags) - } - compId, diags := clients.CompositeIdFromStr(d.Id()) - if diags.HasError() { - return nil, fmt.Errorf("failed to parse provided ID") - } - indexName := compId.ResourceId - index, diags := elasticsearch.GetIndex(ctx, client, indexName) - if diags.HasError() { - return nil, fmt.Errorf("failed to get an ES Index") - } - - // check the settings and import those as well - if index.Settings != nil { - for key, typ := range allSettingsKeys { - var value interface{} - if v, ok := index.Settings[key]; ok { - value = v - } else if v, ok := index.Settings["index."+key]; ok { - value = v - } else { - tflog.Warn(ctx, fmt.Sprintf("setting '%s' is not currently managed by terraform provider and has been ignored", key)) - continue - } - switch typ { - case schema.TypeInt: - v, err := strconv.Atoi(value.(string)) - if err != nil { - return nil, fmt.Errorf("failed to convert setting '%s' value %v to int: %w", key, value, err) - } - value = v - case schema.TypeBool: - v, err := strconv.ParseBool(value.(string)) - if err != nil { - return nil, fmt.Errorf("failed to convert setting '%s' value %v to bool: %w", key, value, err) - } - value = v - } - if err := d.Set(utils.ConvertSettingsKeyToTFFieldKey(key), value); err != nil { - return nil, err - } - } - } - return []*schema.ResourceData{d}, nil - }, - }, - - CustomizeDiff: customdiff.ForceNewIfChange("mappings", func(ctx context.Context, old, new, meta interface{}) bool { - o := make(map[string]interface{}) - if err := json.NewDecoder(strings.NewReader(old.(string))).Decode(&o); err != nil { - return true - } - n := make(map[string]interface{}) - if err := json.NewDecoder(strings.NewReader(new.(string))).Decode(&n); err != nil { - return true - } - tflog.Trace(ctx, "mappings custom diff old = %+v new = %+v", o, n) - - // if old defined we must check if the type of the existing fields were changed - if oldProps, ok := o["properties"]; ok { - newProps, ok := n["properties"] - // if the old has props but new one not, immediately force new resource - if !ok { - return true - } - return IsMappingForceNewRequired(ctx, oldProps.(map[string]interface{}), newProps.(map[string]interface{})) - } - - // if all check passed, we can update the map - return false - }), - - Schema: indexSchema, - } -} - -func resourceIndexCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client, diags := clients.NewApiClientFromSDKResource(d, meta) - if diags.HasError() { - return diags - } - indexName := d.Get("name").(string) - id, diags := client.ID(ctx, indexName) - if diags.HasError() { - return diags - } - var index models.Index - index.Name = indexName - - if v, ok := d.GetOk("alias"); ok { - aliases := v.(*schema.Set) - als, diags := ExpandIndexAliases(aliases) - if diags.HasError() { - return diags - } - index.Aliases = als - } - - if v, ok := d.GetOk("mappings"); ok { - maps := make(map[string]interface{}) - if v.(string) != "" { - if err := json.Unmarshal([]byte(v.(string)), &maps); err != nil { - return diag.FromErr(err) - } - } - index.Mappings = maps - } - - index.Settings = map[string]interface{}{} - if settings := utils.ExpandIndividuallyDefinedSettings(ctx, d, allSettingsKeys); len(settings) > 0 { - index.Settings = settings - } - - analysis := map[string]interface{}{} - if analyzerJSON, ok := d.GetOk("analysis_analyzer"); ok { - var analyzer map[string]interface{} - bytes := []byte(analyzerJSON.(string)) - err := json.Unmarshal(bytes, &analyzer) - if err != nil { - return diag.FromErr(err) - } - analysis["analyzer"] = analyzer - } - if tokenizerJSON, ok := d.GetOk("analysis_tokenizer"); ok { - var tokenizer map[string]interface{} - bytes := []byte(tokenizerJSON.(string)) - err := json.Unmarshal(bytes, &tokenizer) - if err != nil { - return diag.FromErr(err) - } - analysis["tokenizer"] = tokenizer - } - if charFilterJSON, ok := d.GetOk("analysis_char_filter"); ok { - var filter map[string]interface{} - bytes := []byte(charFilterJSON.(string)) - if err := json.Unmarshal(bytes, &filter); err != nil { - return diag.FromErr(err) - } - analysis["char_filter"] = filter - } - if filterJSON, ok := d.GetOk("analysis_filter"); ok { - var filter map[string]interface{} - bytes := []byte(filterJSON.(string)) - err := json.Unmarshal(bytes, &filter) - if err != nil { - return diag.FromErr(err) - } - analysis["filter"] = filter - } - if normalizerJSON, ok := d.GetOk("analysis_normalizer"); ok { - var normalizer map[string]interface{} - bytes := []byte(normalizerJSON.(string)) - err := json.Unmarshal(bytes, &normalizer) - if err != nil { - return diag.FromErr(err) - } - analysis["normalizer"] = normalizer - } - if len(analysis) > 0 { - index.Settings["analysis"] = analysis - } - - if v, ok := d.GetOk("settings"); ok { - // we know at this point we have 1 and only 1 `settings` block defined - managedSettings := v.([]interface{})[0].(map[string]interface{})["setting"].(*schema.Set) - for _, s := range managedSettings.List() { - setting := s.(map[string]interface{}) - name := setting["name"].(string) - if _, ok := index.Settings[name]; ok { - return diag.FromErr(fmt.Errorf("setting '%s' is already defined by the other field, please remove it from `settings` to avoid unexpected settings", name)) - } - index.Settings[name] = setting["value"] - } - } - - serverVersion, diags := client.ServerVersion(ctx) - if diags.HasError() { - return diags - } - - serverFlavor, diags := client.ServerFlavor(ctx) - if diags.HasError() { - return diags - } - - params := models.PutIndexParams{ - IncludeTypeName: d.Get("include_type_name").(bool), - } - - if includeTypeName := d.Get("include_type_name").(bool); includeTypeName { - if serverVersion.GreaterThanOrEqual(includeTypeNameMinUnsupportedVersion) { - return diag.FromErr(fmt.Errorf("'include_type_name' field is supported only for elasticsearch v7.x")) - } - params.IncludeTypeName = includeTypeName - } - - if serverFlavor != "serverless" { - params.WaitForActiveShards = d.Get("wait_for_active_shards").(string) - masterTimeout, err := time.ParseDuration(d.Get("master_timeout").(string)) - if err != nil { - return diag.FromErr(err) - } - params.MasterTimeout = masterTimeout - } - - timeout, err := time.ParseDuration(d.Get("timeout").(string)) - if err != nil { - return diag.FromErr(err) - } - params.Timeout = timeout - - if diags := elasticsearch.PutIndex(ctx, client, &index, ¶ms); diags.HasError() { - return diags - } - - d.SetId(id.String()) - return resourceIndexRead(ctx, d, meta) -} - -// Because of limitation of ES API we must handle changes to aliases, mappings and settings separately -func resourceIndexUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client, diags := clients.NewApiClientFromSDKResource(d, meta) - if diags.HasError() { - return diags - } - indexName := d.Get("name").(string) - - // aliases - if d.HasChange("alias") { - oldAliases, newAliases := d.GetChange("alias") - eold, diags := ExpandIndexAliases(oldAliases.(*schema.Set)) - if diags.HasError() { - return diags - } - enew, diags := ExpandIndexAliases(newAliases.(*schema.Set)) - if diags.HasError() { - return diags - } - - aliasesToDelete := make([]string, 0) - // iterate old aliases and decide which aliases to be deleted - for k := range eold { - if _, ok := enew[k]; !ok { - // delete the alias - aliasesToDelete = append(aliasesToDelete, k) - } - } - if len(aliasesToDelete) > 0 { - if diags := elasticsearch.DeleteIndexAlias(ctx, client, indexName, aliasesToDelete); diags.HasError() { - return diags - } - } - - // keep new aliases up-to-date - for _, v := range enew { - if diags := elasticsearch.UpdateIndexAlias(ctx, client, indexName, &v); diags.HasError() { - return diags - } - } - } - - // settings - updatedSettings := make(map[string]interface{}) - for key := range dynamicsSettingsKeys { - fieldKey := utils.ConvertSettingsKeyToTFFieldKey(key) - if d.HasChange(fieldKey) { - updatedSettings[key] = d.Get(fieldKey) - } - } - if d.HasChange("settings") { - oldSettings, newSettings := d.GetChange("settings") - os := flattenIndexSettings(oldSettings.([]interface{})) - ns := flattenIndexSettings(newSettings.([]interface{})) - tflog.Trace(ctx, fmt.Sprintf("Change in the settings detected old settings = %+v, new settings = %+v", os, ns)) - // make sure to add setting to the new map which were removed - for k, ov := range os { - if _, ok := ns[k]; !ok { - ns[k] = nil - } - // remove the keys if the new value matches old one - // we need to update only changed settings - if nv, ok := ns[k]; ok && nv == ov { - delete(ns, k) - } - } - for k, v := range ns { - if _, ok := updatedSettings[k]; ok && v != nil { - return diag.FromErr(fmt.Errorf("setting '%s' is already updated by the other field, please remove it from `settings` to avoid unexpected settings", k)) - } else { - updatedSettings[k] = v - } - } - } - if len(updatedSettings) > 0 { - tflog.Trace(ctx, fmt.Sprintf("settings to update: %+v", updatedSettings)) - if diags := elasticsearch.UpdateIndexSettings(ctx, client, indexName, updatedSettings); diags.HasError() { - return diags - } - } - - // mappings - if d.HasChange("mappings") { - // at this point we know there are mappings defined and there is a change which we can apply - mappings := d.Get("mappings").(string) - if diags := elasticsearch.UpdateIndexMappings(ctx, client, indexName, mappings); diags.HasError() { - return diags - } - } - - return resourceIndexRead(ctx, d, meta) -} - -func flattenIndexSettings(settings []interface{}) map[string]interface{} { - ns := make(map[string]interface{}) - if len(settings) > 0 { - s := settings[0].(map[string]interface{})["setting"].(*schema.Set) - for _, v := range s.List() { - vv := v.(map[string]interface{}) - ns[vv["name"].(string)] = vv["value"] - } - } - return ns -} - -func resourceIndexRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client, diags := clients.NewApiClientFromSDKResource(d, meta) - if diags.HasError() { - return diags - } - compId, diags := clients.CompositeIdFromStr(d.Id()) - if diags.HasError() { - return diags - } - indexName := compId.ResourceId - - if err := d.Set("name", indexName); err != nil { - return diag.FromErr(err) - } - - index, diags := elasticsearch.GetIndex(ctx, client, indexName) - if index == nil && diags == nil { - // no index found on ES side - tflog.Warn(ctx, fmt.Sprintf(`Index "%s" not found, removing from state`, compId.ResourceId)) - d.SetId("") - return diags - } - if diags.HasError() { - return diags - } - - if index.Aliases != nil { - aliases, diags := FlattenIndexAliases(index.Aliases) - if diags.HasError() { - return diags - } - if err := d.Set("alias", aliases); err != nil { - return diag.FromErr(err) - } - } - if index.Mappings != nil { - m, err := json.Marshal(index.Mappings) - if err != nil { - return diag.FromErr(err) - } - if err := d.Set("mappings", string(m)); err != nil { - return diag.FromErr(err) - } - } - // TODO: We ideally should set read settings to each field to detect changes - // But for now, setting it will cause unexpected diff for the existing clients which use `settings` - if index.Settings != nil { - s, err := json.Marshal(index.Settings) - if err != nil { - return diag.FromErr(err) - } - if err := d.Set("settings_raw", string(s)); err != nil { - return diag.FromErr(err) - } - } - return diags -} - -func resourceIndexDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - if d.Get("deletion_protection").(bool) { - return diag.Errorf("cannot destroy index without setting deletion_protection=false and running `terraform apply`") - } - client, diags := clients.NewApiClientFromSDKResource(d, meta) - if diags.HasError() { - return diags - } - id := d.Id() - compId, diags := clients.CompositeIdFromStr(id) - if diags.HasError() { - return diags - } - if diags := elasticsearch.DeleteIndex(ctx, client, compId.ResourceId); diags.HasError() { - return diags - } - return diags -} - -func IsMappingForceNewRequired(ctx context.Context, old map[string]interface{}, new map[string]interface{}) bool { - for k, v := range old { - oldFieldSettings := v.(map[string]interface{}) - newFieldSettings, ok := new[k] - // When field is removed, it'll be ignored in elasticsearch - if !ok { - tflog.Warn(ctx, fmt.Sprintf("removing %s field in mappings is ignored. Re-index to remove the field completely.", k)) - continue - } - newSettings := newFieldSettings.(map[string]interface{}) - // check if the "type" field exists and match with new one - if s, ok := oldFieldSettings["type"]; ok { - if ns, ok := newSettings["type"]; ok { - if !reflect.DeepEqual(s, ns) { - return true - } - continue - } else { - return true - } - } - - // if we have "mapping" field, let's call ourself to check again - if s, ok := oldFieldSettings["properties"]; ok { - if ns, ok := newSettings["properties"]; ok { - if IsMappingForceNewRequired(context.Background(), s.(map[string]interface{}), ns.(map[string]interface{})) { - return true - } - } else { - tflog.Warn(ctx, fmt.Sprintf("removing %s propeties in mappings is ignored, if you neeed to remove it completely, please recreate the index", k)) - } - } - } - return false -} diff --git a/internal/elasticsearch/index/index_test.go b/internal/elasticsearch/index/index/acc_test.go similarity index 56% rename from internal/elasticsearch/index/index_test.go rename to internal/elasticsearch/index/index/acc_test.go index b0e34ccae..e109802fb 100644 --- a/internal/elasticsearch/index/index_test.go +++ b/internal/elasticsearch/index/index/acc_test.go @@ -1,14 +1,12 @@ package index_test import ( - "context" "fmt" "regexp" "testing" "github.com/elastic/terraform-provider-elasticstack/internal/acctest" "github.com/elastic/terraform-provider-elasticstack/internal/clients" - "github.com/elastic/terraform-provider-elasticstack/internal/elasticsearch/index" sdkacctest "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" @@ -26,10 +24,13 @@ func TestAccResourceIndex(t *testing.T) { Config: testAccResourceIndexCreate(indexName), Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test", "name", indexName), - resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test", "alias.0.name", "test_alias_1"), - resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test", "alias.1.name", "test_alias_2"), + resource.TestMatchTypeSetElemNestedAttrs("elasticstack_elasticsearch_index.test", "alias.*", map[string]*regexp.Regexp{ + "name": regexp.MustCompile("test_alias_1"), + }), + resource.TestMatchTypeSetElemNestedAttrs("elasticstack_elasticsearch_index.test", "alias.*", map[string]*regexp.Regexp{ + "name": regexp.MustCompile("test_alias_2"), + }), resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test", "alias.#", "2"), - resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test", "settings.0.setting.#", "3"), ), }, { @@ -42,25 +43,42 @@ func TestAccResourceIndex(t *testing.T) { Config: testAccResourceIndexUpdate(indexName), Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test", "name", indexName), - resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test", "alias.0.name", "test_alias_1"), - resource.TestCheckNoResourceAttr("elasticstack_elasticsearch_index.test", "alias.1"), + resource.TestMatchTypeSetElemNestedAttrs("elasticstack_elasticsearch_index.test", "alias.*", map[string]*regexp.Regexp{ + "name": regexp.MustCompile("test_alias_1"), + }), resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test", "alias.#", "1"), - resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test", "settings.#", "0"), + ), + }, + { + Config: testAccResourceIndexZeroReplicas(indexName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test", "name", indexName), + resource.TestMatchTypeSetElemNestedAttrs("elasticstack_elasticsearch_index.test", "alias.*", map[string]*regexp.Regexp{ + "name": regexp.MustCompile("test_alias_1"), + }), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test", "alias.#", "1"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test", "number_of_replicas", "0"), ), }, }, }) } -func TestAccResourceIndexSettings(t *testing.T) { +func TestAccResourceIndexFromSDK(t *testing.T) { indexName := sdkacctest.RandStringFromCharSet(22, sdkacctest.CharSetAlphaNum) resource.Test(t, resource.TestCase{ - PreCheck: func() { acctest.PreCheck(t) }, - CheckDestroy: checkResourceIndexDestroy, - ProtoV6ProviderFactories: acctest.Providers, + PreCheck: func() { acctest.PreCheck(t) }, + CheckDestroy: checkResourceIndexDestroy, Steps: []resource.TestStep{ { + // Create the index with the last provider version where the index resource was built on the SDK + ExternalProviders: map[string]resource.ExternalProvider{ + "elasticstack": { + Source: "elastic/elasticstack", + VersionConstraint: "0.11.3", + }, + }, Config: testAccResourceIndexSettingsCreate(indexName), Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "name", indexName), @@ -99,40 +117,51 @@ func TestAccResourceIndexSettings(t *testing.T) { resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "settings.0.setting.0.value", "2"), ), }, - }, - }) -} - -func TestAccResourceIndexSettingsMigration(t *testing.T) { - indexName := sdkacctest.RandStringFromCharSet(22, sdkacctest.CharSetAlphaNum) - - resource.Test(t, resource.TestCase{ - PreCheck: func() { acctest.PreCheck(t) }, - CheckDestroy: checkResourceIndexDestroy, - ProtoV6ProviderFactories: acctest.Providers, - Steps: []resource.TestStep{ - { - Config: testAccResourceIndexSettingsMigrationCreate(indexName), - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings_migration", "name", indexName), - resource.TestCheckNoResourceAttr("elasticstack_elasticsearch_index.test_settings_migration", "number_of_replicas"), - resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings_migration", "settings.0.setting.0.name", "number_of_replicas"), - resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings_migration", "settings.0.setting.0.value", "2"), - ), - }, { - Config: testAccResourceIndexSettingsMigrationUpdate(indexName), + ProtoV6ProviderFactories: acctest.Providers, + Config: testAccResourceIndexSettingsCreate(indexName), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings_migration", "name", indexName), - resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings_migration", "number_of_replicas", "1"), - resource.TestCheckNoResourceAttr("elasticstack_elasticsearch_index.test_settings_migration", "settings.#"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "name", indexName), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "number_of_shards", "2"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "number_of_routing_shards", "2"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "codec", "best_compression"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "routing_partition_size", "1"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "shard_check_on_startup", "false"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "sort_field.0", "sort_key"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "sort_order.0", "asc"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "mapping_coerce", "true"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "auto_expand_replicas", "0-5"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "search_idle_after", "30s"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "refresh_interval", "10s"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "max_result_window", "5000"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "max_inner_result_window", "2000"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "max_rescore_window", "1000"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "max_docvalue_fields_search", "1500"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "max_script_fields", "500"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "max_ngram_diff", "100"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "max_shingle_diff", "200"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "max_refresh_listeners", "10"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "analyze_max_token_count", "500000"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "highlight_max_analyzed_offset", "1000"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "max_terms_count", "10000"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "max_regex_length", "1000"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "query_default_field.0", "field1"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "routing_allocation_enable", "primaries"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "routing_rebalance_enable", "primaries"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "gc_deletes", "30s"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "unassigned_node_left_delayed_timeout", "5m"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "analysis_analyzer", `{"text_en":{"char_filter":"zero_width_spaces","filter":["lowercase","minimal_english_stemmer"],"tokenizer":"standard","type":"custom"}}`), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "analysis_char_filter", `{"zero_width_spaces":{"mappings":["\\u200C=\u003e\\u0020"],"type":"mapping"}}`), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "analysis_filter", `{"minimal_english_stemmer":{"language":"minimal_english","type":"stemmer"}}`), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "settings.0.setting.0.name", "number_of_replicas"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "settings.0.setting.0.value", "2"), ), }, }, }) } -func TestAccResourceIndexSettingsConflict(t *testing.T) { +func TestAccResourceIndexSettings(t *testing.T) { indexName := sdkacctest.RandStringFromCharSet(22, sdkacctest.CharSetAlphaNum) resource.Test(t, resource.TestCase{ @@ -141,8 +170,43 @@ func TestAccResourceIndexSettingsConflict(t *testing.T) { ProtoV6ProviderFactories: acctest.Providers, Steps: []resource.TestStep{ { - Config: testAccResourceIndexSettingsConflict(indexName), - ExpectError: regexp.MustCompile("setting 'number_of_shards' is already defined by the other field, please remove it from `settings` to avoid unexpected settings"), + Config: testAccResourceIndexSettingsCreate(indexName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "name", indexName), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "number_of_shards", "2"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "number_of_routing_shards", "2"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "codec", "best_compression"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "routing_partition_size", "1"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "shard_check_on_startup", "false"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "sort_field.0", "sort_key"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "sort_order.0", "asc"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "mapping_coerce", "true"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "auto_expand_replicas", "0-5"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "search_idle_after", "30s"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "refresh_interval", "10s"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "max_result_window", "5000"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "max_inner_result_window", "2000"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "max_rescore_window", "1000"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "max_docvalue_fields_search", "1500"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "max_script_fields", "500"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "max_ngram_diff", "100"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "max_shingle_diff", "200"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "max_refresh_listeners", "10"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "analyze_max_token_count", "500000"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "highlight_max_analyzed_offset", "1000"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "max_terms_count", "10000"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "max_regex_length", "1000"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "query_default_field.0", "field1"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "routing_allocation_enable", "primaries"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "routing_rebalance_enable", "primaries"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "gc_deletes", "30s"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "unassigned_node_left_delayed_timeout", "5m"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "analysis_analyzer", `{"text_en":{"char_filter":"zero_width_spaces","filter":["lowercase","minimal_english_stemmer"],"tokenizer":"standard","type":"custom"}}`), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "analysis_char_filter", `{"zero_width_spaces":{"mappings":["\\u200C=\u003e\\u0020"],"type":"mapping"}}`), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "analysis_filter", `{"minimal_english_stemmer":{"language":"minimal_english","type":"stemmer"}}`), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "settings.0.setting.0.name", "number_of_replicas"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index.test_settings", "settings.0.setting.0.value", "2"), + ), }, }, }) @@ -176,6 +240,7 @@ resource "elasticstack_elasticsearch_index" "test" { alias { name = "test_alias_1" } + alias { name = "test_alias_2" filter = jsonencode({ @@ -189,21 +254,6 @@ resource "elasticstack_elasticsearch_index" "test" { } }) - settings { - setting { - name = "index.number_of_replicas" - value = "2" - } - setting { - name = "index.routing.allocation.total_shards_per_node" - value = "200" - } - setting { - name = "index.search.idle.after" - value = "20s" - } - } - wait_for_active_shards = "all" master_timeout = "1m" timeout = "1m" @@ -235,6 +285,31 @@ resource "elasticstack_elasticsearch_index" "test" { `, name) } +func testAccResourceIndexZeroReplicas(name string) string { + return fmt.Sprintf(` +provider "elasticstack" { + elasticsearch {} +} + +resource "elasticstack_elasticsearch_index" "test" { + name = "%s" + number_of_replicas = 0 + + alias { + name = "test_alias_1" + } + + mappings = jsonencode({ + properties = { + field1 = { type = "text" } + } + }) + + deletion_protection = false +} + `, name) +} + func testAccResourceIndexSettingsCreate(name string) string { return fmt.Sprintf(` provider "elasticstack" { @@ -313,72 +388,6 @@ resource "elasticstack_elasticsearch_index" "test_settings" { `, name) } -func testAccResourceIndexSettingsMigrationCreate(name string) string { - return fmt.Sprintf(` -provider "elasticstack" { - elasticsearch {} -} - -resource "elasticstack_elasticsearch_index" "test_settings_migration" { - name = "%s" - - settings { - setting { - name = "number_of_replicas" - value = "2" - } - } - - deletion_protection = false -} - `, name) -} - -func testAccResourceIndexSettingsMigrationUpdate(name string) string { - return fmt.Sprintf(` -provider "elasticstack" { - elasticsearch {} -} - -resource "elasticstack_elasticsearch_index" "test_settings_migration" { - name = "%s" - - number_of_replicas = 1 - - deletion_protection = false -} - `, name) -} - -func testAccResourceIndexSettingsConflict(name string) string { - return fmt.Sprintf(` -provider "elasticstack" { - elasticsearch {} -} - -resource "elasticstack_elasticsearch_index" "test_settings_conflict" { - name = "%s" - - mappings = jsonencode({ - properties = { - field1 = { type = "text" } - } - }) - - number_of_shards = 2 - - settings { - setting { - name = "number_of_shards" - value = "3" - } - } - - deletion_protection = false -} - `, name) -} - func testAccResourceIndexRemovingFieldCreate(name string) string { return fmt.Sprintf(` provider "elasticstack" { @@ -468,103 +477,3 @@ func checkResourceIndexDestroy(s *terraform.State) error { } return nil } - -func Test_IsMappingForceNewRequired(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - old map[string]interface{} - new map[string]interface{} - want bool - }{ - { - name: "return false only when new field is added", - old: map[string]interface{}{ - "field1": map[string]interface{}{ - "type": "text", - }, - }, - new: map[string]interface{}{ - "field1": map[string]interface{}{ - "type": "text", - }, - "field2": map[string]interface{}{ - "type": "keyword", - }, - }, - want: false, - }, - { - name: "return true when type is changed", - old: map[string]interface{}{ - "field1": map[string]interface{}{ - "type": "text", - }, - }, - new: map[string]interface{}{ - "field1": map[string]interface{}{ - "type": "integer", - }, - }, - want: true, - }, - { - name: "return false when field is removed", - old: map[string]interface{}{ - "field1": map[string]interface{}{ - "type": "text", - }, - }, - new: map[string]interface{}{}, - want: false, - }, - { - name: "return false when dynamically added child property is removed", - old: map[string]interface{}{ - "parent": map[string]interface{}{ - "properties": map[string]interface{}{ - "child": map[string]interface{}{ - "type": "keyword", - }, - }, - }, - }, - new: map[string]interface{}{ - "parent": map[string]interface{}{ - "type": "object", - }, - }, - want: false, - }, - { - name: "return true when child property's type changes", - old: map[string]interface{}{ - "parent": map[string]interface{}{ - "properties": map[string]interface{}{ - "child": map[string]interface{}{ - "type": "keyword", - }, - }, - }, - }, - new: map[string]interface{}{ - "parent": map[string]interface{}{ - "properties": map[string]interface{}{ - "child": map[string]interface{}{ - "type": "integer", - }, - }, - }, - }, - want: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := index.IsMappingForceNewRequired(context.Background(), tt.old, tt.new); got != tt.want { - t.Errorf("IsMappingForceNewRequired() = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/internal/elasticsearch/index/index/create.go b/internal/elasticsearch/index/index/create.go new file mode 100644 index 000000000..e2799d2ef --- /dev/null +++ b/internal/elasticsearch/index/index/create.go @@ -0,0 +1,83 @@ +package index + +import ( + "context" + "fmt" + + "github.com/elastic/terraform-provider-elasticstack/internal/clients" + "github.com/elastic/terraform-provider-elasticstack/internal/clients/elasticsearch" + "github.com/elastic/terraform-provider-elasticstack/internal/utils" + "github.com/hashicorp/go-version" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var includeTypeNameMinUnsupportedVersion = version.Must(version.NewVersion("8.0.0")) + +func (r Resource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + if !r.resourceReady(&resp.Diagnostics) { + return + } + + var planModel tfModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &planModel)...) + if resp.Diagnostics.HasError() { + return + } + + client, diags := clients.MaybeNewApiClientFromFrameworkResource(ctx, planModel.ElasticsearchConnection, r.client) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + name := planModel.Name.ValueString() + id, sdkDiags := client.ID(ctx, name) + if sdkDiags.HasError() { + resp.Diagnostics.Append(utils.FrameworkDiagsFromSDK(sdkDiags)...) + return + } + + planModel.ID = types.StringValue(id.String()) + apiModel, diags := planModel.toAPIModel(ctx) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + serverFlavor, sdkDiags := client.ServerFlavor(ctx) + if sdkDiags.HasError() { + resp.Diagnostics.Append(utils.FrameworkDiagsFromSDK(sdkDiags)...) + return + } + + params := planModel.toPutIndexParams(serverFlavor) + serverVersion, sdkDiags := client.ServerVersion(ctx) + if sdkDiags.HasError() { + resp.Diagnostics.Append(utils.FrameworkDiagsFromSDK(sdkDiags)...) + return + } + + if params.IncludeTypeName && serverVersion.GreaterThanOrEqual(includeTypeNameMinUnsupportedVersion) { + resp.Diagnostics.AddAttributeError( + path.Root("include_type_name"), + "'include_type_name' field is only supported for Elasticsearch v7.x", + fmt.Sprintf("'include_type_name' field is only supported for Elasticsearch v7.x. Got %s", serverVersion), + ) + return + } + + resp.Diagnostics.Append(elasticsearch.PutIndex(ctx, client, &apiModel, ¶ms)...) + if resp.Diagnostics.HasError() { + return + } + + finalModel, diags := readIndex(ctx, planModel, client) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, finalModel)...) +} diff --git a/internal/elasticsearch/index/index/delete.go b/internal/elasticsearch/index/index/delete.go new file mode 100644 index 000000000..bb11c4ddc --- /dev/null +++ b/internal/elasticsearch/index/index/delete.go @@ -0,0 +1,45 @@ +package index + +import ( + "context" + + "github.com/elastic/terraform-provider-elasticstack/internal/clients" + "github.com/elastic/terraform-provider-elasticstack/internal/clients/elasticsearch" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +func (r *Resource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + if !r.resourceReady(&resp.Diagnostics) { + return + } + + var model tfModel + resp.Diagnostics.Append(req.State.Get(ctx, &model)...) + if resp.Diagnostics.HasError() { + return + } + + if model.DeletionProtection.ValueBool() { + resp.Diagnostics.AddAttributeError( + path.Root("deletion_protection"), + "cannot destroy index without setting deletion_protection=false and running `terraform apply`", + "cannot destroy index without setting deletion_protection=false and running `terraform apply`", + ) + return + } + + client, diags := clients.MaybeNewApiClientFromFrameworkResource(ctx, model.ElasticsearchConnection, r.client) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + id, diags := model.GetID() + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(elasticsearch.DeleteIndex(ctx, client, id.ResourceId)...) +} diff --git a/internal/elasticsearch/index/index/mapping_modifier.go b/internal/elasticsearch/index/index/mapping_modifier.go new file mode 100644 index 000000000..6d0568764 --- /dev/null +++ b/internal/elasticsearch/index/index/mapping_modifier.go @@ -0,0 +1,110 @@ +package index + +import ( + "context" + "encoding/json" + "fmt" + "reflect" + + "github.com/elastic/terraform-provider-elasticstack/internal/utils" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +type mappingsPlanModifier struct{} + +func (p mappingsPlanModifier) PlanModifyString(ctx context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) { + if !utils.IsKnown(req.StateValue) { + return + } + + if !utils.IsKnown(req.ConfigValue) { + return + } + + stateStr := req.StateValue.ValueString() + cfgStr := req.ConfigValue.ValueString() + + var stateMappings map[string]interface{} + var cfgMappings map[string]interface{} + + // No error checking, schema validation ensures this is valid json + _ = json.Unmarshal([]byte(stateStr), &stateMappings) + _ = json.Unmarshal([]byte(cfgStr), &cfgMappings) + + if stateProps, ok := stateMappings["properties"]; ok { + cfgProps, ok := cfgMappings["properties"] + if !ok { + resp.RequiresReplace = true + return + } + + requiresReplace, finalMappings, diags := p.modifyMappings(ctx, path.Root("mappings").AtMapKey("properties"), stateProps.(map[string]interface{}), cfgProps.(map[string]interface{})) + resp.RequiresReplace = requiresReplace + cfgMappings["properties"] = finalMappings + resp.Diagnostics.Append(diags...) + + planBytes, err := json.Marshal(cfgMappings) + if err != nil { + resp.Diagnostics.AddAttributeError(req.Path, "Failed to marshal final mappings", err.Error()) + return + } + + resp.PlanValue = basetypes.NewStringValue(string(planBytes)) + } +} + +func (p mappingsPlanModifier) modifyMappings(ctx context.Context, initialPath path.Path, old map[string]interface{}, new map[string]interface{}) (bool, map[string]interface{}, diag.Diagnostics) { + var diags diag.Diagnostics + for k, v := range old { + oldFieldSettings := v.(map[string]interface{}) + newFieldSettings, ok := new[k] + currentPath := initialPath.AtMapKey(k) + // When field is removed, it'll be ignored in elasticsearch + if !ok { + diags.AddAttributeWarning(path.Root("mappings"), fmt.Sprintf("removing field [%s] in mappings is ignored.", currentPath), "Elasticsearch will maintain the current field in it's mapping. Re-index to remove the field completely") + new[k] = v + continue + } + newSettings := newFieldSettings.(map[string]interface{}) + // check if the "type" field exists and match with new one + if s, ok := oldFieldSettings["type"]; ok { + if ns, ok := newSettings["type"]; ok { + if !reflect.DeepEqual(s, ns) { + return true, new, diags + } + continue + } else { + return true, new, diags + } + } + + // if we have "mapping" field, let's call ourself to check again + if s, ok := oldFieldSettings["properties"]; ok { + currentPath = currentPath.AtMapKey("properties") + if ns, ok := newSettings["properties"]; ok { + requiresReplace, newProperties, d := p.modifyMappings(ctx, currentPath, s.(map[string]interface{}), ns.(map[string]interface{})) + diags.Append(d...) + newSettings["properties"] = newProperties + if requiresReplace { + return true, new, diags + } + } else { + diags.AddAttributeWarning(path.Root("mappings"), fmt.Sprintf("removing field [%s] in mappings is ignored.", currentPath), "Elasticsearch will maintain the current field in it's mapping. Re-index to remove the field completely") + newSettings["properties"] = s + } + } + } + + return false, new, diags +} + +func (p mappingsPlanModifier) Description(_ context.Context) string { + return "Preserves existing mappings which don't exist in config" +} + +func (p mappingsPlanModifier) MarkdownDescription(ctx context.Context) string { + return p.Description(ctx) +} diff --git a/internal/elasticsearch/index/index/mapping_modifier_test.go b/internal/elasticsearch/index/index/mapping_modifier_test.go new file mode 100644 index 000000000..c38b8d356 --- /dev/null +++ b/internal/elasticsearch/index/index/mapping_modifier_test.go @@ -0,0 +1,264 @@ +package index + +import ( + "context" + "encoding/json" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/stretchr/testify/require" +) + +func mapToJsonStringValue(t *testing.T, m map[string]interface{}) basetypes.StringValue { + mBytes, err := json.Marshal(m) + require.NoError(t, err) + + return types.StringValue(string(mBytes)) +} + +func Test_PlanModifyString(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + stateMappings basetypes.StringValue + configMappings basetypes.StringValue + expectedPlanMappings basetypes.StringValue + expectedDiags diag.Diagnostics + expectedRequiresReplace bool + }{ + { + name: "should do nothing if the state value is unknown", + stateMappings: basetypes.NewStringUnknown(), + configMappings: basetypes.NewStringValue("{}"), + }, + { + name: "should do nothing if the state value is null", + stateMappings: basetypes.NewStringNull(), + configMappings: basetypes.NewStringValue("{}"), + }, + { + name: "should do nothing if the config value is unknown", + configMappings: basetypes.NewStringUnknown(), + stateMappings: basetypes.NewStringValue("{}"), + }, + { + name: "should do nothing if the config value is null", + configMappings: basetypes.NewStringNull(), + stateMappings: basetypes.NewStringValue("{}"), + }, + { + name: "should do nothing if the state mappings do not define any properties", + stateMappings: mapToJsonStringValue(t, map[string]interface{}{ + "not_properties": map[string]interface{}{ + "hello": "world", + }, + }), + configMappings: basetypes.NewStringValue("{}"), + }, + { + name: "requires replace if state mappings define properties but the config value does not", + stateMappings: mapToJsonStringValue(t, map[string]interface{}{ + "properties": map[string]interface{}{ + "hello": "world", + }, + }), + configMappings: basetypes.NewStringValue("{}"), + expectedRequiresReplace: true, + }, + { + name: "should not alter the final plan when a new field is added", + stateMappings: mapToJsonStringValue(t, map[string]interface{}{ + "properties": map[string]interface{}{ + "field1": map[string]interface{}{ + "type": "string", + }, + }, + }), + configMappings: mapToJsonStringValue(t, map[string]interface{}{ + "properties": map[string]interface{}{ + "field1": map[string]interface{}{ + "type": "string", + }, + "field2": map[string]interface{}{ + "type": "string", + }, + }, + }), + expectedPlanMappings: mapToJsonStringValue(t, map[string]interface{}{ + "properties": map[string]interface{}{ + "field1": map[string]interface{}{ + "type": "string", + }, + "field2": map[string]interface{}{ + "type": "string", + }, + }, + }), + }, + { + name: "requires replace when the type of an existing field is changed", + stateMappings: mapToJsonStringValue(t, map[string]interface{}{ + "properties": map[string]interface{}{ + "field1": map[string]interface{}{ + "type": "string", + }, + }, + }), + configMappings: mapToJsonStringValue(t, map[string]interface{}{ + "properties": map[string]interface{}{ + "field1": map[string]interface{}{ + "type": "int", + }, + }, + }), + expectedPlanMappings: mapToJsonStringValue(t, map[string]interface{}{ + "properties": map[string]interface{}{ + "field1": map[string]interface{}{ + "type": "int", + }, + }, + }), + expectedRequiresReplace: true, + }, + { + name: "should add the removed field to the plan and include a warning when a field is removed from config", + stateMappings: mapToJsonStringValue(t, map[string]interface{}{ + "properties": map[string]interface{}{ + "field1": map[string]interface{}{ + "type": "string", + }, + "field2": map[string]interface{}{ + "type": "string", + }, + }, + }), + configMappings: mapToJsonStringValue(t, map[string]interface{}{ + "properties": map[string]interface{}{ + "field1": map[string]interface{}{ + "type": "string", + }, + }, + }), + expectedPlanMappings: mapToJsonStringValue(t, map[string]interface{}{ + "properties": map[string]interface{}{ + "field1": map[string]interface{}{ + "type": "string", + }, + "field2": map[string]interface{}{ + "type": "string", + }, + }, + }), + expectedDiags: diag.Diagnostics{ + diag.NewAttributeWarningDiagnostic( + path.Root("mappings"), + `removing field [mappings["properties"]["field2"]] in mappings is ignored.`, + "Elasticsearch will maintain the current field in it's mapping. Re-index to remove the field completely", + ), + }, + }, + { + name: "should add the removed field to the plan and include a warning when a sub-field is removed from config", + stateMappings: mapToJsonStringValue(t, map[string]interface{}{ + "properties": map[string]interface{}{ + "field1": map[string]interface{}{ + "properties": map[string]interface{}{ + "field2": map[string]interface{}{ + "type": "string", + }, + }, + }, + }, + }), + configMappings: mapToJsonStringValue(t, map[string]interface{}{ + "properties": map[string]interface{}{ + "field1": map[string]interface{}{ + "properties": map[string]interface{}{ + "field3": map[string]interface{}{ + "type": "string", + }, + }, + }, + }, + }), + expectedPlanMappings: mapToJsonStringValue(t, map[string]interface{}{ + "properties": map[string]interface{}{ + "field1": map[string]interface{}{ + "properties": map[string]interface{}{ + "field2": map[string]interface{}{ + "type": "string", + }, + "field3": map[string]interface{}{ + "type": "string", + }, + }, + }, + }, + }), + expectedDiags: diag.Diagnostics{ + diag.NewAttributeWarningDiagnostic( + path.Root("mappings"), + `removing field [mappings["properties"]["field1"]["properties"]["field2"]] in mappings is ignored.`, + "Elasticsearch will maintain the current field in it's mapping. Re-index to remove the field completely", + ), + }, + }, + { + name: "requires replace when a sub-fields type is changed", + stateMappings: mapToJsonStringValue(t, map[string]interface{}{ + "properties": map[string]interface{}{ + "field1": map[string]interface{}{ + "properties": map[string]interface{}{ + "field2": map[string]interface{}{ + "type": "string", + }, + }, + }, + }, + }), + configMappings: mapToJsonStringValue(t, map[string]interface{}{ + "properties": map[string]interface{}{ + "field1": map[string]interface{}{ + "properties": map[string]interface{}{ + "field2": map[string]interface{}{ + "type": "int", + }, + }, + }, + }, + }), + expectedPlanMappings: mapToJsonStringValue(t, map[string]interface{}{ + "properties": map[string]interface{}{ + "field1": map[string]interface{}{ + "properties": map[string]interface{}{ + "field2": map[string]interface{}{ + "type": "int", + }, + }, + }, + }, + }), + expectedRequiresReplace: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + modifier := mappingsPlanModifier{} + resp := planmodifier.StringResponse{} + modifier.PlanModifyString(context.Background(), planmodifier.StringRequest{ + ConfigValue: tt.configMappings, + StateValue: tt.stateMappings, + }, &resp) + + require.Equal(t, tt.expectedDiags, resp.Diagnostics) + require.Equal(t, tt.expectedPlanMappings, resp.PlanValue) + require.Equal(t, tt.expectedRequiresReplace, resp.RequiresReplace) + }) + } +} diff --git a/internal/elasticsearch/index/index/models.go b/internal/elasticsearch/index/index/models.go new file mode 100644 index 000000000..047b8f1d7 --- /dev/null +++ b/internal/elasticsearch/index/index/models.go @@ -0,0 +1,642 @@ +package index + +import ( + "context" + "encoding/json" + "fmt" + "reflect" + "strconv" + "strings" + + "github.com/elastic/terraform-provider-elasticstack/internal/clients" + "github.com/elastic/terraform-provider-elasticstack/internal/models" + "github.com/elastic/terraform-provider-elasticstack/internal/utils" + "github.com/elastic/terraform-provider-elasticstack/internal/utils/customtypes" + "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +var ( + staticSettingsKeys = []string{ + "number_of_shards", + "number_of_routing_shards", + "codec", + "routing_partition_size", + "load_fixed_bitset_filters_eagerly", + "shard.check_on_startup", + "sort.field", + "sort.order", + "mapping.coerce", + } + dynamicSettingsKeys = []string{ + "number_of_replicas", + "auto_expand_replicas", + "refresh_interval", + "search.idle.after", + "max_result_window", + "max_inner_result_window", + "max_rescore_window", + "max_docvalue_fields_search", + "max_script_fields", + "max_ngram_diff", + "max_shingle_diff", + "blocks.read_only", + "blocks.read_only_allow_delete", + "blocks.read", + "blocks.write", + "blocks.metadata", + "max_refresh_listeners", + "analyze.max_token_count", + "highlight.max_analyzed_offset", + "max_terms_count", + "max_regex_length", + "query.default_field", + "routing.allocation.enable", + "routing.rebalance.enable", + "gc_deletes", + "default_pipeline", + "final_pipeline", + "unassigned.node_left.delayed_timeout", + "search.slowlog.threshold.query.warn", + "search.slowlog.threshold.query.info", + "search.slowlog.threshold.query.debug", + "search.slowlog.threshold.query.trace", + "search.slowlog.threshold.fetch.warn", + "search.slowlog.threshold.fetch.info", + "search.slowlog.threshold.fetch.debug", + "search.slowlog.threshold.fetch.trace", + "search.slowlog.level", + "indexing.slowlog.threshold.index.warn", + "indexing.slowlog.threshold.index.info", + "indexing.slowlog.threshold.index.debug", + "indexing.slowlog.threshold.index.trace", + "indexing.slowlog.level", + "indexing.slowlog.source", + } + allSettingsKeys = []string{} +) + +func init() { + allSettingsKeys = append(allSettingsKeys, staticSettingsKeys...) + allSettingsKeys = append(allSettingsKeys, dynamicSettingsKeys...) +} + +type tfModel struct { + ID types.String `tfsdk:"id"` + ElasticsearchConnection types.List `tfsdk:"elasticsearch_connection"` + Name types.String `tfsdk:"name"` + NumberOfShards types.Int64 `tfsdk:"number_of_shards"` + NumberOfRoutingShards types.Int64 `tfsdk:"number_of_routing_shards"` + Codec types.String `tfsdk:"codec"` + RoutingPartitionSize types.Int64 `tfsdk:"routing_partition_size"` + LoadFixedBitsetFiltersEagerly types.Bool `tfsdk:"load_fixed_bitset_filters_eagerly"` + ShardCheckOnStartup types.String `tfsdk:"shard_check_on_startup"` + SortField types.Set `tfsdk:"sort_field"` + SortOrder types.List `tfsdk:"sort_order"` + MappingCoerce types.Bool `tfsdk:"mapping_coerce"` + NumberOfReplicas types.Int64 `tfsdk:"number_of_replicas"` + AutoExpandReplicas types.String `tfsdk:"auto_expand_replicas"` + SearchIdleAfter types.String `tfsdk:"search_idle_after"` + RefreshInterval types.String `tfsdk:"refresh_interval"` + MaxResultWindow types.Int64 `tfsdk:"max_result_window"` + MaxInnerResultWindow types.Int64 `tfsdk:"max_inner_result_window"` + MaxRescoreWindow types.Int64 `tfsdk:"max_rescore_window"` + MaxDocvalueFieldsSearch types.Int64 `tfsdk:"max_docvalue_fields_search"` + MaxScriptFields types.Int64 `tfsdk:"max_script_fields"` + MaxNGramDiff types.Int64 `tfsdk:"max_ngram_diff"` + MaxShingleDiff types.Int64 `tfsdk:"max_shingle_diff"` + MaxRefreshListeners types.Int64 `tfsdk:"max_refresh_listeners"` + AnalyzeMaxTokenCount types.Int64 `tfsdk:"analyze_max_token_count"` + HighlightMaxAnalyzedOffset types.Int64 `tfsdk:"highlight_max_analyzed_offset"` + MaxTermsCount types.Int64 `tfsdk:"max_terms_count"` + MaxRegexLength types.Int64 `tfsdk:"max_regex_length"` + QueryDefaultField types.Set `tfsdk:"query_default_field"` + RoutingAllocationEnable types.String `tfsdk:"routing_allocation_enable"` + RoutingRebalanceEnable types.String `tfsdk:"routing_rebalance_enable"` + GCDeletes types.String `tfsdk:"gc_deletes"` + BlocksReadOnly types.Bool `tfsdk:"blocks_read_only"` + BlocksReadOnlyAllowDelete types.Bool `tfsdk:"blocks_read_only_allow_delete"` + BlocksRead types.Bool `tfsdk:"blocks_read"` + BlocksWrite types.Bool `tfsdk:"blocks_write"` + BlocksMetadata types.Bool `tfsdk:"blocks_metadata"` + DefaultPipeline types.String `tfsdk:"default_pipeline"` + FinalPipeline types.String `tfsdk:"final_pipeline"` + UnassignedNodeLeftDelayedTimeout types.String `tfsdk:"unassigned_node_left_delayed_timeout"` + SearchSlowlogThresholdQueryWarn types.String `tfsdk:"search_slowlog_threshold_query_warn"` + SearchSlowlogThresholdQueryInfo types.String `tfsdk:"search_slowlog_threshold_query_info"` + SearchSlowlogThresholdQueryDebug types.String `tfsdk:"search_slowlog_threshold_query_debug"` + SearchSlowlogThresholdQueryTrace types.String `tfsdk:"search_slowlog_threshold_query_trace"` + SearchSlowlogThresholdFetchWarn types.String `tfsdk:"search_slowlog_threshold_fetch_warn"` + SearchSlowlogThresholdFetchInfo types.String `tfsdk:"search_slowlog_threshold_fetch_info"` + SearchSlowlogThresholdFetchDebug types.String `tfsdk:"search_slowlog_threshold_fetch_debug"` + SearchSlowlogThresholdFetchTrace types.String `tfsdk:"search_slowlog_threshold_fetch_trace"` + SearchSlowlogLevel types.String `tfsdk:"search_slowlog_level"` + IndexingSlowlogThresholdIndexWarn types.String `tfsdk:"indexing_slowlog_threshold_index_warn"` + IndexingSlowlogThresholdIndexInfo types.String `tfsdk:"indexing_slowlog_threshold_index_info"` + IndexingSlowlogThresholdIndexDebug types.String `tfsdk:"indexing_slowlog_threshold_index_debug"` + IndexingSlowlogThresholdIndexTrace types.String `tfsdk:"indexing_slowlog_threshold_index_trace"` + IndexingSlowlogLevel types.String `tfsdk:"indexing_slowlog_level"` + IndexingSlowlogSource types.String `tfsdk:"indexing_slowlog_source"` + AnalysisAnalyzer jsontypes.Normalized `tfsdk:"analysis_analyzer"` + AnalysisTokenizer jsontypes.Normalized `tfsdk:"analysis_tokenizer"` + AnalysisCharFilter jsontypes.Normalized `tfsdk:"analysis_char_filter"` + AnalysisFilter jsontypes.Normalized `tfsdk:"analysis_filter"` + AnalysisNormalizer jsontypes.Normalized `tfsdk:"analysis_normalizer"` + Alias types.Set `tfsdk:"alias"` + Mappings jsontypes.Normalized `tfsdk:"mappings"` + SettingsRaw jsontypes.Normalized `tfsdk:"settings_raw"` + DeletionProtection types.Bool `tfsdk:"deletion_protection"` + IncludeTypeName types.Bool `tfsdk:"include_type_name"` + WaitForActiveShards types.String `tfsdk:"wait_for_active_shards"` + MasterTimeout customtypes.Duration `tfsdk:"master_timeout"` + Timeout customtypes.Duration `tfsdk:"timeout"` + Settings types.List `tfsdk:"settings"` +} + +type aliasTfModel struct { + Name types.String `tfsdk:"name"` + Filter jsontypes.Normalized `tfsdk:"filter"` + IndexRouting types.String `tfsdk:"index_routing"` + IsHidden types.Bool `tfsdk:"is_hidden"` + IsWriteIndex types.Bool `tfsdk:"is_write_index"` + Routing types.String `tfsdk:"routing"` + SearchRouting types.String `tfsdk:"search_routing"` +} + +type settingsTfSet struct { + Setting types.Set `tfsdk:"setting"` +} + +type settingTfModel struct { + Name types.String `tfsdk:"name"` + Value types.String `tfsdk:"value"` +} + +func (model *tfModel) populateFromAPI(ctx context.Context, indexName string, apiModel models.Index) diag.Diagnostics { + model.Name = types.StringValue(indexName) + modelAliases, diags := aliasesFromAPI(ctx, apiModel) + if diags.HasError() { + return diags + } + + model.Alias = modelAliases + + if apiModel.Mappings != nil { + mappingBytes, err := json.Marshal(apiModel.Mappings) + if err != nil { + return diag.Diagnostics{ + diag.NewErrorDiagnostic("failed to marshal index mappings", err.Error()), + } + } + + model.Mappings = jsontypes.NewNormalizedValue(string(mappingBytes)) + } + + diags = setSettingsFromAPI(ctx, model, apiModel) + if diags.HasError() { + return diags + } + + return nil +} + +func aliasesFromAPI(ctx context.Context, apiModel models.Index) (basetypes.SetValue, diag.Diagnostics) { + aliases := []aliasTfModel{} + for name, alias := range apiModel.Aliases { + tfAlias, diags := newAliasModelFromAPI(name, alias) + if diags.HasError() { + return basetypes.SetValue{}, diags + } + + aliases = append(aliases, tfAlias) + } + + modelAliases, diags := types.SetValueFrom(ctx, aliasElementType(), aliases) + if diags.HasError() { + return basetypes.SetValue{}, diags + } + + return modelAliases, nil +} + +func setSettingsFromAPI(ctx context.Context, model *tfModel, apiModel models.Index) diag.Diagnostics { + modelType := reflect.TypeOf(*model) + + for _, key := range dynamicSettingsKeys { + settingsValue, ok := apiModel.Settings["index."+key] + var tfValue attr.Value + if !ok { + continue + } + + tfFieldKey := convertSettingsKeyToTFFieldKey(key) + value, ok := model.getFieldValueByTagValue(tfFieldKey, modelType) + if !ok { + return diag.Diagnostics{ + diag.NewErrorDiagnostic( + "failed to find setting value", + fmt.Sprintf("expected setting with key %s", tfFieldKey), + ), + } + } + + switch a := value.(type) { + case types.String: + settingStr, ok := settingsValue.(string) + if !ok { + return diag.Diagnostics{ + diag.NewErrorDiagnostic( + "failed to convert setting to string", + fmt.Sprintf("expected setting to be a string but got %t", settingsValue), + )} + } + tfValue = basetypes.NewStringValue(settingStr) + case types.Bool: + settingBool, ok := settingsValue.(bool) + if !ok { + return diag.Diagnostics{ + diag.NewErrorDiagnostic( + "failed to convert setting to bool", + fmt.Sprintf("expected setting to be a bool but got %t", settingsValue), + )} + } + tfValue = basetypes.NewBoolValue(settingBool) + case types.Int64: + if settingStr, ok := settingsValue.(string); ok { + settingInt, err := strconv.Atoi(settingStr) + if err != nil { + return diag.Diagnostics{ + diag.NewErrorDiagnostic( + "failed to convert setting to int", + fmt.Sprintf("expected setting to be an int but it was a string. Attempted to parse it but got %s", err.Error()), + ), + } + } + + settingsValue = int64(settingInt) + } + + settingInt, ok := settingsValue.(int64) + if !ok { + return diag.Diagnostics{ + diag.NewErrorDiagnostic( + "failed to convert setting to int", + fmt.Sprintf("expected setting to be a int but got %t", settingsValue), + )} + } + tfValue = basetypes.NewInt64Value(settingInt) + case types.List: + elemType := a.ElementType(ctx) + if elemType != types.StringType { + return diag.Diagnostics{ + diag.NewErrorDiagnostic( + "expected list of string", + fmt.Sprintf("expected list element type to be string but got %s", elemType), + ), + } + } + + elems, ok := settingsValue.([]interface{}) + if !ok { + return diag.Diagnostics{ + diag.NewErrorDiagnostic( + "failed to convert setting to []string", + fmt.Sprintf("expected setting to be a []string but got %#v", settingsValue), + )} + } + + var diags diag.Diagnostics + tfValue, diags = basetypes.NewListValueFrom(ctx, basetypes.StringType{}, elems) + if diags.HasError() { + return diags + } + case types.Set: + elemType := a.ElementType(ctx) + if elemType != types.StringType { + return diag.Diagnostics{ + diag.NewErrorDiagnostic( + "expected set of string", + fmt.Sprintf("expected set element type to be string but got %s", elemType), + ), + } + } + + elems, ok := settingsValue.([]interface{}) + if !ok { + return diag.Diagnostics{ + diag.NewErrorDiagnostic( + "failed to convert setting to []string", + fmt.Sprintf("expected setting to be a thing []string but got %#v", settingsValue), + )} + } + + var diags diag.Diagnostics + tfValue, diags = basetypes.NewSetValueFrom(ctx, basetypes.StringType{}, elems) + if diags.HasError() { + return diags + } + default: + return diag.Diagnostics{ + diag.NewErrorDiagnostic( + "unknown value type", + fmt.Sprintf("unknown index setting value type %s", a.Type(ctx)), + ), + } + } + + ok = model.setFieldValueByTagValue(tfFieldKey, modelType, tfValue) + if !ok { + return diag.Diagnostics{ + diag.NewErrorDiagnostic( + "failed to find setting value", + fmt.Sprintf("expected setting with key %s", tfFieldKey), + ), + } + } + } + + settingsBytes, err := json.Marshal(apiModel.Settings) + if err != nil { + return diag.Diagnostics{ + diag.NewErrorDiagnostic( + "failed to marshal raw settings", + err.Error(), + ), + } + } + + model.SettingsRaw = jsontypes.NewNormalizedValue(string(settingsBytes)) + + return nil +} + +func (model tfModel) toAPIModel(ctx context.Context) (models.Index, diag.Diagnostics) { + var diags diag.Diagnostics + + apiModel := models.Index{ + Name: model.Name.ValueString(), + Settings: map[string]interface{}{}, + } + + if utils.IsKnown(model.Alias) { + apiModel.Aliases = map[string]models.IndexAlias{} + + var planAliases []aliasTfModel + diags.Append(model.Alias.ElementsAs(ctx, &planAliases, true)...) + if diags.HasError() { + return models.Index{}, diags + } + + for _, planAlias := range planAliases { + apiAlias, diags := planAlias.toAPIModel() + if diags.HasError() { + return models.Index{}, diags + } + + apiModel.Aliases[apiAlias.Name] = apiAlias + } + } + + settings, diags := model.toIndexSettings(ctx) + if diags.HasError() { + return models.Index{}, diags + } + + apiModel.Settings = settings + + if utils.IsKnown(model.Mappings) { + diags.Append(model.Mappings.Unmarshal(&apiModel.Mappings)...) + if diags.HasError() { + return models.Index{}, diags + } + } + + return apiModel, diags +} + +func (model tfModel) toPutIndexParams(serverFlavor string) models.PutIndexParams { + // The string values are validated as durations as part of schema validation + masterTimeout, _ := model.MasterTimeout.Parse() + timeout, _ := model.Timeout.Parse() + + params := models.PutIndexParams{ + MasterTimeout: masterTimeout, + Timeout: timeout, + } + + if serverFlavor != "serverless" { + params.WaitForActiveShards = model.WaitForActiveShards.ValueString() + params.IncludeTypeName = model.IncludeTypeName.ValueBool() + } + + return params +} + +func (model tfModel) GetID() (*clients.CompositeId, diag.Diagnostics) { + compId, sdkDiags := clients.CompositeIdFromStr(model.ID.ValueString()) + if sdkDiags.HasError() { + return nil, utils.FrameworkDiagsFromSDK(sdkDiags) + } + + return compId, nil +} + +func (model tfModel) toIndexSettings(ctx context.Context) (map[string]interface{}, diag.Diagnostics) { + settings := map[string]interface{}{} + modelType := reflect.TypeOf(model) + + for _, key := range allSettingsKeys { + tfFieldKey := convertSettingsKeyToTFFieldKey(key) + value, ok := model.getFieldValueByTagValue(tfFieldKey, modelType) + if !ok { + return map[string]interface{}{}, diag.Diagnostics{ + diag.NewErrorDiagnostic( + "failed to find setting value", + fmt.Sprintf("expected setting with key %s", tfFieldKey), + ), + } + } + + if !value.IsNull() && !value.IsUnknown() { + var settingsValue interface{} + switch a := value.(type) { + case types.String: + settingsValue = a.ValueString() + case types.Bool: + settingsValue = a.ValueBool() + case types.Int64: + settingsValue = a.ValueInt64() + case types.List: + elemType := a.ElementType(ctx) + if elemType != types.StringType { + return map[string]interface{}{}, diag.Diagnostics{ + diag.NewErrorDiagnostic( + "expected list of string", + fmt.Sprintf("expected list element type to be string but got %s", elemType), + ), + } + } + + elems := []string{} + if diags := a.ElementsAs(ctx, &elems, true); diags.HasError() { + return map[string]interface{}{}, diags + } + + settingsValue = elems + case types.Set: + elemType := a.ElementType(ctx) + if elemType != types.StringType { + return map[string]interface{}{}, diag.Diagnostics{ + diag.NewErrorDiagnostic( + "expected set of string", + fmt.Sprintf("expected set element type to be string but got %s", elemType), + ), + } + } + + elems := []string{} + if diags := a.ElementsAs(ctx, &elems, true); diags.HasError() { + return map[string]interface{}{}, diags + } + + settingsValue = elems + default: + return map[string]interface{}{}, diag.Diagnostics{ + diag.NewErrorDiagnostic( + "unknown value type", + fmt.Sprintf("unknown index setting value type %s", a.Type(ctx)), + ), + } + } + + settings[key] = settingsValue + } + } + + analysisProperties := map[string]jsontypes.Normalized{ + "analyzer": model.AnalysisAnalyzer, + "tokenizer": model.AnalysisTokenizer, + "char_filter": model.AnalysisCharFilter, + "filter": model.AnalysisFilter, + "normalizer": model.AnalysisNormalizer, + } + + analysis := map[string]interface{}{} + for name, property := range analysisProperties { + if utils.IsKnown(property) { + var parsedValue map[string]interface{} + if diags := property.Unmarshal(&parsedValue); diags.HasError() { + return map[string]interface{}{}, diags + } + + analysis[name] = parsedValue + } + } + + if len(analysis) > 0 { + settings["analysis"] = analysis + } + + var settingSet []settingsTfSet + if diags := model.Settings.ElementsAs(ctx, &settingSet, true); diags.HasError() { + return map[string]interface{}{}, diags + } + + if len(settingSet) == 1 { + var rawSettings []settingTfModel + if diags := settingSet[0].Setting.ElementsAs(ctx, &rawSettings, true); diags.HasError() { + return map[string]interface{}{}, diags + } + + for _, setting := range rawSettings { + name := setting.Name.ValueString() + if _, ok := settings[name]; ok { + return map[string]interface{}{}, diag.Diagnostics{ + diag.NewErrorDiagnostic( + "duplicate setting definition", + fmt.Sprintf("setting [%s] is both explicitly defined and included in the deprecated raw settings blocks. Please remove it from `settings` to avoid unexpected settings", name), + ), + } + } + + settings[name] = setting.Value.ValueString() + } + } + + return settings, nil +} + +func (model *tfModel) setFieldValueByTagValue(tagName string, t reflect.Type, value attr.Value) bool { + numField := t.NumField() + for i := 0; i < numField; i++ { + field := t.Field(i) + if field.Tag.Get("tfsdk") == tagName { + reflect.ValueOf(model).Elem().Field(i).Set(reflect.ValueOf(value)) + return true + } + } + + return false +} + +func (model tfModel) getFieldValueByTagValue(tagName string, t reflect.Type) (attr.Value, bool) { + numField := t.NumField() + for i := 0; i < numField; i++ { + field := t.Field(i) + if field.Tag.Get("tfsdk") == tagName { + return reflect.ValueOf(model).Field(i).Interface().(attr.Value), true + } + } + + return nil, false +} + +func convertSettingsKeyToTFFieldKey(settingKey string) string { + return strings.Replace(settingKey, ".", "_", -1) +} + +func (model aliasTfModel) toAPIModel() (models.IndexAlias, diag.Diagnostics) { + apiModel := models.IndexAlias{ + Name: model.Name.ValueString(), + IndexRouting: model.IndexRouting.ValueString(), + IsHidden: model.IsHidden.ValueBool(), + IsWriteIndex: model.IsWriteIndex.ValueBool(), + Routing: model.Routing.ValueString(), + SearchRouting: model.SearchRouting.ValueString(), + } + + if utils.IsKnown(model.Filter) { + if diags := model.Filter.Unmarshal(&apiModel.Filter); diags.HasError() { + return models.IndexAlias{}, diags + } + } + + return apiModel, nil +} + +func newAliasModelFromAPI(name string, apiModel models.IndexAlias) (aliasTfModel, diag.Diagnostics) { + tfAlias := aliasTfModel{ + Name: types.StringValue(name), + IndexRouting: types.StringValue(apiModel.IndexRouting), + IsHidden: types.BoolValue(apiModel.IsHidden), + IsWriteIndex: types.BoolValue(apiModel.IsWriteIndex), + Routing: types.StringValue(apiModel.Routing), + SearchRouting: types.StringValue(apiModel.SearchRouting), + } + + if apiModel.Filter != nil { + filterBytes, err := json.Marshal(apiModel.Filter) + if err != nil { + return aliasTfModel{}, diag.Diagnostics{ + diag.NewErrorDiagnostic("failed to marshal alias filter", err.Error()), + } + } + + tfAlias.Filter = jsontypes.NewNormalizedValue(string(filterBytes)) + } + + return tfAlias, nil +} diff --git a/internal/elasticsearch/index/index/models_test.go b/internal/elasticsearch/index/index/models_test.go new file mode 100644 index 000000000..0845ca8b5 --- /dev/null +++ b/internal/elasticsearch/index/index/models_test.go @@ -0,0 +1,376 @@ +package index + +import ( + "context" + "fmt" + "testing" + + fuzz "github.com/google/gofuzz" + + "github.com/elastic/terraform-provider-elasticstack/internal/models" + "github.com/elastic/terraform-provider-elasticstack/internal/utils/customtypes" + "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/stretchr/testify/require" +) + +func Test_tfModel_toAPIModel(t *testing.T) { + validAliases, diags := basetypes.NewSetValueFrom( + context.Background(), + aliasElementType(), + []aliasTfModel{ + {Name: basetypes.NewStringValue("alias-0")}, + { + Name: basetypes.NewStringValue("alias-1"), + IndexRouting: basetypes.NewStringValue("fast"), + IsHidden: basetypes.NewBoolValue(false), + IsWriteIndex: basetypes.NewBoolValue(true), + Routing: basetypes.NewStringValue("slow"), + SearchRouting: basetypes.NewStringValue("just_right"), + Filter: jsontypes.NewNormalizedValue(`{"a": "b"}`), + }, + }, + ) + require.Empty(t, diags) + + validSetting, diags := basetypes.NewSetValueFrom( + context.Background(), + settingElementType(), + []settingTfModel{ + {Name: basetypes.NewStringValue("number_of_replicas"), Value: basetypes.NewStringValue("5")}, + }, + ) + require.Empty(t, diags) + + validSettingsBlock, diags := basetypes.NewObjectValue( + map[string]attr.Type{"setting": basetypes.SetType{ElemType: settingElementType()}}, + map[string]attr.Value{ + "setting": validSetting, + }, + ) + require.Empty(t, diags) + + validSettings, diags := basetypes.NewListValue( + settingsElementType(), + []attr.Value{validSettingsBlock}, + ) + require.Empty(t, diags) + + tests := []struct { + name string + model tfModel + expectedApiModel models.Index + hasError bool + expectedDiags diag.Diagnostics + }{ + { + name: "should not populate aliases if null", + model: tfModel{ + Name: basetypes.NewStringValue("index-name"), + Alias: basetypes.NewSetNull(basetypes.ObjectType{}), + Settings: basetypes.NewListNull(basetypes.ObjectType{}), + }, + expectedApiModel: models.Index{ + Name: "index-name", + Settings: map[string]interface{}{}, + }, + }, + { + name: "should not populate aliases if unknown", + model: tfModel{ + Name: basetypes.NewStringValue("index-name"), + Alias: basetypes.NewSetUnknown(basetypes.ObjectType{}), + Settings: basetypes.NewListNull(basetypes.ObjectType{}), + }, + expectedApiModel: models.Index{ + Name: "index-name", + Settings: map[string]interface{}{}, + }, + }, + { + name: "should populate aliases if provided", + model: tfModel{ + Name: basetypes.NewStringValue("index-name"), + Alias: validAliases, + Settings: basetypes.NewListNull(basetypes.ObjectType{}), + }, + expectedApiModel: models.Index{ + Name: "index-name", + Settings: map[string]interface{}{}, + Aliases: map[string]models.IndexAlias{ + "alias-0": {Name: "alias-0"}, + "alias-1": { + Name: "alias-1", + IndexRouting: "fast", + IsHidden: false, + IsWriteIndex: true, + Routing: "slow", + SearchRouting: "just_right", + Filter: map[string]interface{}{ + "a": "b", + }, + }, + }, + }, + }, + { + name: "should not populate mappings if null", + model: tfModel{ + Name: basetypes.NewStringValue("index-name"), + Mappings: jsontypes.NewNormalizedNull(), + Settings: basetypes.NewListNull(basetypes.ObjectType{}), + }, + expectedApiModel: models.Index{ + Name: "index-name", + Settings: map[string]interface{}{}, + }, + }, + { + name: "should not populate mappings if unknown", + model: tfModel{ + Name: basetypes.NewStringValue("index-name"), + Mappings: jsontypes.NewNormalizedUnknown(), + Settings: basetypes.NewListNull(basetypes.ObjectType{}), + }, + expectedApiModel: models.Index{ + Name: "index-name", + Settings: map[string]interface{}{}, + }, + }, + { + name: "should unmarshall mappings if provided", + model: tfModel{ + Name: basetypes.NewStringValue("index-name"), + Mappings: jsontypes.NewNormalizedValue(`{"a": "b"}`), + Settings: basetypes.NewListNull(basetypes.ObjectType{}), + }, + expectedApiModel: models.Index{ + Name: "index-name", + Settings: map[string]interface{}{}, + Mappings: map[string]interface{}{ + "a": "b", + }, + }, + }, + { + name: "should fail to parse a set of non-strings for sort field", + model: tfModel{ + Name: basetypes.NewStringValue("index-name"), + SortField: basetypes.NewSetValueMust(basetypes.Int64Type{}, []attr.Value{basetypes.NewInt64Value(1)}), + Settings: basetypes.NewListNull(basetypes.ObjectType{}), + }, + hasError: true, + expectedDiags: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "expected set of string", + "expected set element type to be string but got basetypes.Int64Type", + ), + }, + }, + { + name: "should fail to parse a list of non-string for sort order", + model: tfModel{ + Name: basetypes.NewStringValue("index-name"), + SortOrder: basetypes.NewListValueMust(basetypes.Int64Type{}, []attr.Value{basetypes.NewInt64Value(1)}), + Settings: basetypes.NewListNull(basetypes.ObjectType{}), + }, + hasError: true, + expectedDiags: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "expected list of string", + "expected list element type to be string but got basetypes.Int64Type", + ), + }, + }, + { + name: "should build settings map from model", + model: tfModel{ + Name: basetypes.NewStringValue("index-name"), + NumberOfShards: basetypes.NewInt64Value(3), + NumberOfRoutingShards: basetypes.NewInt64Value(5), + Codec: basetypes.NewStringValue("codec"), + RoutingPartitionSize: basetypes.NewInt64Value(7), + LoadFixedBitsetFiltersEagerly: basetypes.NewBoolValue(true), + ShardCheckOnStartup: basetypes.NewStringValue("shard_check_on_startup"), + SortField: basetypes.NewSetValueMust(basetypes.StringType{}, []attr.Value{basetypes.NewStringValue("sort_field")}), + SortOrder: basetypes.NewListValueMust(basetypes.StringType{}, []attr.Value{basetypes.NewStringValue("sort_order")}), + MappingCoerce: basetypes.NewBoolValue(false), + NumberOfReplicas: basetypes.NewInt64Value(9), + AutoExpandReplicas: basetypes.NewStringValue("auto_expand_replicas"), + RefreshInterval: basetypes.NewStringValue("refresh_interval"), + SearchIdleAfter: basetypes.NewStringValue("search.idle.after"), + MaxResultWindow: basetypes.NewInt64Value(11), + MaxInnerResultWindow: basetypes.NewInt64Value(13), + MaxRescoreWindow: basetypes.NewInt64Value(15), + MaxDocvalueFieldsSearch: basetypes.NewInt64Value(17), + MaxScriptFields: basetypes.NewInt64Value(19), + MaxNGramDiff: basetypes.NewInt64Value(21), + MaxShingleDiff: basetypes.NewInt64Value(23), + BlocksReadOnly: basetypes.NewBoolValue(true), + BlocksReadOnlyAllowDelete: basetypes.NewBoolValue(true), + BlocksRead: basetypes.NewBoolValue(true), + BlocksWrite: basetypes.NewBoolValue(true), + BlocksMetadata: basetypes.NewBoolValue(true), + MaxRefreshListeners: basetypes.NewInt64Value(25), + AnalyzeMaxTokenCount: basetypes.NewInt64Value(27), + HighlightMaxAnalyzedOffset: basetypes.NewInt64Value(29), + MaxTermsCount: basetypes.NewInt64Value(31), + MaxRegexLength: basetypes.NewInt64Value(33), + QueryDefaultField: basetypes.NewSetValueMust(basetypes.StringType{}, []attr.Value{basetypes.NewStringValue("query.default_field")}), + RoutingAllocationEnable: basetypes.NewStringValue("routing.allocation.enable"), + RoutingRebalanceEnable: basetypes.NewStringValue("routing.rebalance.enable"), + GCDeletes: basetypes.NewStringValue("gc_deletes"), + DefaultPipeline: basetypes.NewStringValue("default_pipeline"), + FinalPipeline: basetypes.NewStringValue("final_pipeline"), + UnassignedNodeLeftDelayedTimeout: basetypes.NewStringValue("unassigned.node_left.delayed_timeout"), + SearchSlowlogThresholdQueryWarn: basetypes.NewStringValue("warn"), + SearchSlowlogThresholdQueryInfo: basetypes.NewStringValue("info"), + SearchSlowlogThresholdQueryDebug: basetypes.NewStringValue("debug"), + SearchSlowlogThresholdQueryTrace: basetypes.NewStringValue("trace"), + SearchSlowlogThresholdFetchWarn: basetypes.NewStringValue("warn"), + SearchSlowlogThresholdFetchInfo: basetypes.NewStringValue("info"), + SearchSlowlogThresholdFetchDebug: basetypes.NewStringValue("debug"), + SearchSlowlogThresholdFetchTrace: basetypes.NewStringValue("trace"), + SearchSlowlogLevel: basetypes.NewStringValue("level"), + IndexingSlowlogThresholdIndexWarn: basetypes.NewStringValue("warn"), + IndexingSlowlogThresholdIndexInfo: basetypes.NewStringValue("info"), + IndexingSlowlogThresholdIndexDebug: basetypes.NewStringValue("debug"), + IndexingSlowlogThresholdIndexTrace: basetypes.NewStringValue("trace"), + IndexingSlowlogLevel: basetypes.NewStringValue("level"), + IndexingSlowlogSource: basetypes.NewStringValue("source"), + Settings: basetypes.NewListNull(basetypes.ObjectType{}), + }, + expectedApiModel: models.Index{ + Name: "index-name", + Settings: map[string]interface{}{ + "number_of_shards": int64(3), + "number_of_routing_shards": int64(5), + "codec": "codec", + "routing_partition_size": int64(7), + "load_fixed_bitset_filters_eagerly": true, + "shard.check_on_startup": "shard_check_on_startup", + "sort.field": []string{"sort_field"}, + "sort.order": []string{"sort_order"}, + "mapping.coerce": false, + "number_of_replicas": int64(9), + "auto_expand_replicas": "auto_expand_replicas", + "refresh_interval": "refresh_interval", + "search.idle.after": "search.idle.after", + "max_result_window": int64(11), + "max_inner_result_window": int64(13), + "max_rescore_window": int64(15), + "max_docvalue_fields_search": int64(17), + "max_script_fields": int64(19), + "max_ngram_diff": int64(21), + "max_shingle_diff": int64(23), + "blocks.read_only": true, + "blocks.read_only_allow_delete": true, + "blocks.read": true, + "blocks.write": true, + "blocks.metadata": true, + "max_refresh_listeners": int64(25), + "analyze.max_token_count": int64(27), + "highlight.max_analyzed_offset": int64(29), + "max_terms_count": int64(31), + "max_regex_length": int64(33), + "query.default_field": []string{"query.default_field"}, + "routing.allocation.enable": "routing.allocation.enable", + "routing.rebalance.enable": "routing.rebalance.enable", + "gc_deletes": "gc_deletes", + "default_pipeline": "default_pipeline", + "final_pipeline": "final_pipeline", + "unassigned.node_left.delayed_timeout": "unassigned.node_left.delayed_timeout", + "search.slowlog.threshold.query.warn": "warn", + "search.slowlog.threshold.query.info": "info", + "search.slowlog.threshold.query.debug": "debug", + "search.slowlog.threshold.query.trace": "trace", + "search.slowlog.threshold.fetch.warn": "warn", + "search.slowlog.threshold.fetch.info": "info", + "search.slowlog.threshold.fetch.debug": "debug", + "search.slowlog.threshold.fetch.trace": "trace", + "search.slowlog.level": "level", + "indexing.slowlog.threshold.index.warn": "warn", + "indexing.slowlog.threshold.index.info": "info", + "indexing.slowlog.threshold.index.debug": "debug", + "indexing.slowlog.threshold.index.trace": "trace", + "indexing.slowlog.level": "level", + "indexing.slowlog.source": "source", + }, + }, + }, + { + name: "should parse arbitrary settings", + model: tfModel{ + Name: basetypes.NewStringValue("index-name"), + Settings: validSettings, + }, + expectedApiModel: models.Index{ + Name: "index-name", + Settings: map[string]interface{}{ + "number_of_replicas": "5", + }, + }, + }, + { + name: "should fail to parse settings defined in both the type safe attribute, and arbitrary blob", + model: tfModel{ + Name: basetypes.NewStringValue("index-name"), + Settings: validSettings, + NumberOfReplicas: basetypes.NewInt64Value(10), + }, + hasError: true, + expectedDiags: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "duplicate setting definition", + "setting [number_of_replicas] is both explicitly defined and included in the deprecated raw settings blocks. Please remove it from `settings` to avoid unexpected settings", + ), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + apiModel, diags := tt.model.toAPIModel(context.Background()) + + if tt.hasError { + require.NotEmpty(t, diags) + } else { + require.Empty(t, diags) + } + + if tt.expectedDiags != nil { + require.Equal(t, tt.expectedDiags, diags) + } + require.Equal(t, tt.expectedApiModel, apiModel) + }) + } +} + +func Test_tfModel_toPutIndexParams(t *testing.T) { + for _, isServerless := range []bool{true, false} { + t.Run(fmt.Sprintf("isServerless=%t", isServerless), func(t *testing.T) { + f := fuzz.New() + var expectedParams models.PutIndexParams + f.Fuzz(&expectedParams) + + model := tfModel{ + MasterTimeout: customtypes.NewDurationValue(expectedParams.MasterTimeout.String()), + Timeout: customtypes.NewDurationValue(expectedParams.Timeout.String()), + WaitForActiveShards: basetypes.NewStringValue(expectedParams.WaitForActiveShards), + IncludeTypeName: basetypes.NewBoolValue(expectedParams.IncludeTypeName), + } + + flavor := "not_serverless" + if isServerless { + flavor = "serverless" + expectedParams.IncludeTypeName = false + expectedParams.WaitForActiveShards = "" + } + + params := model.toPutIndexParams(flavor) + require.Equal(t, expectedParams, params) + }) + } +} diff --git a/internal/elasticsearch/index/index/read.go b/internal/elasticsearch/index/index/read.go new file mode 100644 index 000000000..28e72a429 --- /dev/null +++ b/internal/elasticsearch/index/index/read.go @@ -0,0 +1,65 @@ +package index + +import ( + "context" + + "github.com/elastic/terraform-provider-elasticstack/internal/clients" + "github.com/elastic/terraform-provider-elasticstack/internal/clients/elasticsearch" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +func (r *Resource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + if !r.resourceReady(&resp.Diagnostics) { + return + } + + var stateModel tfModel + resp.Diagnostics.Append(req.State.Get(ctx, &stateModel)...) + if resp.Diagnostics.HasError() { + return + } + + client, diags := clients.MaybeNewApiClientFromFrameworkResource(ctx, stateModel.ElasticsearchConnection, r.client) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + model, diags := readIndex(ctx, stateModel, client) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if model == nil { + resp.State.RemoveResource(ctx) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, model)...) +} + +func readIndex(ctx context.Context, stateModel tfModel, client *clients.ApiClient) (*tfModel, diag.Diagnostics) { + id, diags := stateModel.GetID() + if diags.HasError() { + return nil, diags + } + + indexName := id.ResourceId + apiModel, diags := elasticsearch.GetIndex(ctx, client, indexName) + if diags.HasError() { + return nil, diags + } + + if apiModel == nil { + return nil, nil + } + + diags = stateModel.populateFromAPI(ctx, indexName, *apiModel) + if diags.HasError() { + return nil, diags + } + + return &stateModel, nil +} diff --git a/internal/elasticsearch/index/index/resource.go b/internal/elasticsearch/index/index/resource.go new file mode 100644 index 000000000..6ae8aadb9 --- /dev/null +++ b/internal/elasticsearch/index/index/resource.go @@ -0,0 +1,35 @@ +package index + +import ( + "context" + + "github.com/elastic/terraform-provider-elasticstack/internal/clients" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +type Resource struct { + client *clients.ApiClient +} + +func (r *Resource) resourceReady(dg *diag.Diagnostics) bool { + if r.client == nil { + dg.AddError( + "Unconfigured Client", + "Expected configured client. Please report this issue to the provider developers.", + ) + + return false + } + return true +} + +func (r *Resource) Configure(ctx context.Context, request resource.ConfigureRequest, response *resource.ConfigureResponse) { + client, diags := clients.ConvertProviderData(request.ProviderData) + response.Diagnostics.Append(diags...) + r.client = client +} + +func (r *Resource) Metadata(ctx context.Context, request resource.MetadataRequest, response *resource.MetadataResponse) { + response.TypeName = request.ProviderTypeName + "_elasticsearch_index" +} diff --git a/internal/elasticsearch/index/index/schema.go b/internal/elasticsearch/index/index/schema.go new file mode 100644 index 000000000..1b07689c7 --- /dev/null +++ b/internal/elasticsearch/index/index/schema.go @@ -0,0 +1,517 @@ +package index + +import ( + "context" + "regexp" + + "github.com/elastic/terraform-provider-elasticstack/internal/elasticsearch/index" + providerschema "github.com/elastic/terraform-provider-elasticstack/internal/schema" + "github.com/elastic/terraform-provider-elasticstack/internal/utils/customtypes" + "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/setplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// Ensure provider defined types fully satisfy framework interfaces +var _ resource.Resource = &Resource{} +var _ resource.ResourceWithConfigure = &Resource{} + +// var _ resource.ResourceWithImportState = &Resource{} + +func (r *Resource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = getSchema() +} + +func getSchema() schema.Schema { + return schema.Schema{ + Description: "Creates Elasticsearch indices. See: https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-create-index.html", + Blocks: map[string]schema.Block{ + "elasticsearch_connection": providerschema.GetEsFWConnectionBlock("elasticsearch_connection", false), + "alias": schema.SetNestedBlock{ + Description: "Aliases for the index.", + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: "Index alias name.", + Required: true, + }, + "filter": schema.StringAttribute{ + Description: "Query used to limit documents the alias can access.", + Optional: true, + CustomType: jsontypes.NormalizedType{}, + }, + "index_routing": schema.StringAttribute{ + Description: "Value used to route indexing operations to a specific shard. If specified, this overwrites the `routing` value for indexing operations.", + Optional: true, + Computed: true, + Default: stringdefault.StaticString(""), + }, + "is_hidden": schema.BoolAttribute{ + Description: "If true, the alias is hidden.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, + "is_write_index": schema.BoolAttribute{ + Description: "If true, the index is the write index for the alias.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, + "routing": schema.StringAttribute{ + Description: "Value used to route indexing and search operations to a specific shard.", + Optional: true, + Computed: true, + Default: stringdefault.StaticString(""), + }, + "search_routing": schema.StringAttribute{ + Description: "Value used to route search operations to a specific shard. If specified, this overwrites the routing value for search operations.", + Optional: true, + Computed: true, + Default: stringdefault.StaticString(""), + }, + }, + }, + }, + "settings": schema.ListNestedBlock{ + Description: `DEPRECATED: Please use dedicated setting field. Configuration options for the index. See, https://www.elastic.co/guide/en/elasticsearch/reference/current/index-modules.html#index-modules-settings. +**NOTE:** Static index settings (see: https://www.elastic.co/guide/en/elasticsearch/reference/current/index-modules.html#_static_index_settings) can be only set on the index creation and later cannot be removed or updated - _apply_ will return error`, + DeprecationMessage: "Using settings makes it easier to misconfigure. Use dedicated field for the each setting instead.", + Validators: []validator.List{ + listvalidator.SizeBetween(1, 1), + }, + NestedObject: schema.NestedBlockObject{ + Blocks: map[string]schema.Block{ + "setting": schema.SetNestedBlock{ + Description: "Defines the setting for the index.", + Validators: []validator.Set{ + setvalidator.SizeAtLeast(1), + }, + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: "The name of the setting to set and track.", + Required: true, + }, + "value": schema.StringAttribute{ + Description: "The value of the setting to set and track.", + Required: true, + }, + }, + }, + }, + }, + }, + }, + }, + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Internal identifier of the resource", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "name": schema.StringAttribute{ + Description: "Name of the index you wish to create.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + stringvalidator.LengthBetween(1, 255), + stringvalidator.NoneOf(".", ".."), + stringvalidator.RegexMatches(regexp.MustCompile(`^[^-_+]`), "cannot start with -, _, +"), + stringvalidator.RegexMatches(regexp.MustCompile(`^[a-z0-9!$%&'()+.;=@[\]^{}~_-]+$`), "must contain lower case alphanumeric characters and selected punctuation, see: https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-create-index.html#indices-create-api-path-params"), + }, + }, + // Static settings that can only be set on creation + "number_of_shards": schema.Int64Attribute{ + Description: "Number of shards for the index. This can be set only on creation.", + Optional: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.RequiresReplace(), + }, + }, + "number_of_routing_shards": schema.Int64Attribute{ + Description: "Value used with number_of_shards to route documents to a primary shard. This can be set only on creation.", + Optional: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.RequiresReplace(), + }, + }, + "codec": schema.StringAttribute{ + Description: "The `default` value compresses stored data with LZ4 compression, but this can be set to `best_compression` which uses DEFLATE for a higher compression ratio. This can be set only on creation.", + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + stringvalidator.OneOf("best_compression"), + }, + }, + "routing_partition_size": schema.Int64Attribute{ + Description: "The number of shards a custom routing value can go to. This can be set only on creation.", + Optional: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.RequiresReplace(), + }, + }, + "load_fixed_bitset_filters_eagerly": schema.BoolAttribute{ + Description: "Indicates whether cached filters are pre-loaded for nested queries. This can be set only on creation.", + Optional: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.RequiresReplace(), + }, + }, + "shard_check_on_startup": schema.StringAttribute{ + Description: "Whether or not shards should be checked for corruption before opening. When corruption is detected, it will prevent the shard from being opened. Accepts `false`, `true`, `checksum`.", + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + stringvalidator.OneOf("false", "true", "checksum"), + }, + }, + "sort_field": schema.SetAttribute{ + ElementType: types.StringType, + Description: "The field to sort shards in this index by.", + Optional: true, + PlanModifiers: []planmodifier.Set{ + setplanmodifier.RequiresReplace(), + }, + }, + // sort_order can't be set type since it can have dup strings like ["asc", "asc"] + "sort_order": schema.ListAttribute{ + ElementType: types.StringType, + Description: "The direction to sort shards in. Accepts `asc`, `desc`.", + Optional: true, + PlanModifiers: []planmodifier.List{ + listplanmodifier.RequiresReplace(), + }, + }, + "mapping_coerce": schema.BoolAttribute{ + Description: "Set index level coercion setting that is applied to all mapping types.", + Optional: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.RequiresReplace(), + }, + }, + // Dynamic settings that can be changed at runtime + "number_of_replicas": schema.Int64Attribute{ + Description: "Number of shard replicas.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + }, + "auto_expand_replicas": schema.StringAttribute{ + Description: "Set the number of replicas to the node count in the cluster. Set to a dash delimited lower and upper bound (e.g. 0-5) or use all for the upper bound (e.g. 0-all)", + Optional: true, + }, + "search_idle_after": schema.StringAttribute{ + Description: "How long a shard can not receive a search or get request until it’s considered search idle.", + Optional: true, + }, + "refresh_interval": schema.StringAttribute{ + Description: "How often to perform a refresh operation, which makes recent changes to the index visible to search. Can be set to `-1` to disable refresh.", + Optional: true, + }, + "max_result_window": schema.Int64Attribute{ + Description: "The maximum value of `from + size` for searches to this index.", + Optional: true, + }, + "max_inner_result_window": schema.Int64Attribute{ + Description: "The maximum value of `from + size` for inner hits definition and top hits aggregations to this index.", + Optional: true, + }, + "max_rescore_window": schema.Int64Attribute{ + Description: "The maximum value of `window_size` for `rescore` requests in searches of this index.", + Optional: true, + }, + "max_docvalue_fields_search": schema.Int64Attribute{ + Description: "The maximum number of `docvalue_fields` that are allowed in a query.", + Optional: true, + }, + "max_script_fields": schema.Int64Attribute{ + Description: "The maximum number of `script_fields` that are allowed in a query.", + Optional: true, + }, + "max_ngram_diff": schema.Int64Attribute{ + Description: "The maximum allowed difference between min_gram and max_gram for NGramTokenizer and NGramTokenFilter.", + Optional: true, + }, + "max_shingle_diff": schema.Int64Attribute{ + Description: "The maximum allowed difference between max_shingle_size and min_shingle_size for ShingleTokenFilter.", + Optional: true, + }, + "max_refresh_listeners": schema.Int64Attribute{ + Description: "Maximum number of refresh listeners available on each shard of the index.", + Optional: true, + }, + "analyze_max_token_count": schema.Int64Attribute{ + Description: "The maximum number of tokens that can be produced using _analyze API.", + Optional: true, + }, + "highlight_max_analyzed_offset": schema.Int64Attribute{ + Description: "The maximum number of characters that will be analyzed for a highlight request.", + Optional: true, + }, + "max_terms_count": schema.Int64Attribute{ + Description: "The maximum number of terms that can be used in Terms Query.", + Optional: true, + }, + "max_regex_length": schema.Int64Attribute{ + Description: "The maximum length of regex that can be used in Regexp Query.", + Optional: true, + }, + "query_default_field": schema.SetAttribute{ + ElementType: types.StringType, + Description: "Wildcard (*) patterns matching one or more fields. Defaults to '*', which matches all fields eligible for term-level queries, excluding metadata fields.", + Optional: true, + }, + "routing_allocation_enable": schema.StringAttribute{ + Description: "Controls shard allocation for this index. It can be set to: `all` , `primaries` , `new_primaries` , `none`.", + Optional: true, + Validators: []validator.String{ + stringvalidator.OneOf("all", "primaries", "new_primaries", "none"), + }, + }, + "routing_rebalance_enable": schema.StringAttribute{ + Description: "Enables shard rebalancing for this index. It can be set to: `all`, `primaries` , `replicas` , `none`.", + Optional: true, + Validators: []validator.String{ + stringvalidator.OneOf("all", "primaries", "replicas", "none"), + }, + }, + "gc_deletes": schema.StringAttribute{ + Description: "The length of time that a deleted document's version number remains available for further versioned operations.", + Optional: true, + }, + "blocks_read_only": schema.BoolAttribute{ + Description: "Set to `true` to make the index and index metadata read only, `false` to allow writes and metadata changes.", + Optional: true, + }, + "blocks_read_only_allow_delete": schema.BoolAttribute{ + Description: "Identical to `index.blocks.read_only` but allows deleting the index to free up resources.", + Optional: true, + }, + "blocks_read": schema.BoolAttribute{ + Description: "Set to `true` to disable read operations against the index.", + Optional: true, + }, + "blocks_write": schema.BoolAttribute{ + Description: "Set to `true` to disable data write operations against the index. This setting does not affect metadata.", + Optional: true, + }, + "blocks_metadata": schema.BoolAttribute{ + Description: "Set to `true` to disable index metadata reads and writes.", + Optional: true, + }, + "default_pipeline": schema.StringAttribute{ + Description: "The default ingest node pipeline for this index. Index requests will fail if the default pipeline is set and the pipeline does not exist.", + Optional: true, + }, + "final_pipeline": schema.StringAttribute{ + Description: "Final ingest pipeline for the index. Indexing requests will fail if the final pipeline is set and the pipeline does not exist. The final pipeline always runs after the request pipeline (if specified) and the default pipeline (if it exists). The special pipeline name _none indicates no ingest pipeline will run.", + Optional: true, + }, + "unassigned_node_left_delayed_timeout": schema.StringAttribute{ + Description: "Time to delay the allocation of replica shards which become unassigned because a node has left, in time units, e.g. `10s`", + Optional: true, + }, + "search_slowlog_threshold_query_warn": schema.StringAttribute{ + Description: "Set the cutoff for shard level slow search logging of slow searches in the query phase, in time units, e.g. `10s`", + Optional: true, + }, + "search_slowlog_threshold_query_info": schema.StringAttribute{ + Description: "Set the cutoff for shard level slow search logging of slow searches in the query phase, in time units, e.g. `5s`", + Optional: true, + }, + "search_slowlog_threshold_query_debug": schema.StringAttribute{ + Description: "Set the cutoff for shard level slow search logging of slow searches in the query phase, in time units, e.g. `2s`", + Optional: true, + }, + "search_slowlog_threshold_query_trace": schema.StringAttribute{ + Description: "Set the cutoff for shard level slow search logging of slow searches in the query phase, in time units, e.g. `500ms`", + Optional: true, + }, + "search_slowlog_threshold_fetch_warn": schema.StringAttribute{ + Description: "Set the cutoff for shard level slow search logging of slow searches in the fetch phase, in time units, e.g. `10s`", + Optional: true, + }, + "search_slowlog_threshold_fetch_info": schema.StringAttribute{ + Description: "Set the cutoff for shard level slow search logging of slow searches in the fetch phase, in time units, e.g. `5s`", + Optional: true, + }, + "search_slowlog_threshold_fetch_debug": schema.StringAttribute{ + Description: "Set the cutoff for shard level slow search logging of slow searches in the fetch phase, in time units, e.g. `2s`", + Optional: true, + }, + "search_slowlog_threshold_fetch_trace": schema.StringAttribute{ + Description: "Set the cutoff for shard level slow search logging of slow searches in the fetch phase, in time units, e.g. `500ms`", + Optional: true, + }, + "search_slowlog_level": schema.StringAttribute{ + Description: "Set which logging level to use for the search slow log, can be: `warn`, `info`, `debug`, `trace`", + Optional: true, + Validators: []validator.String{ + stringvalidator.OneOf("warn", "info", "debug", "trace"), + }, + }, + "indexing_slowlog_threshold_index_warn": schema.StringAttribute{ + Description: "Set the cutoff for shard level slow search logging of slow searches for indexing queries, in time units, e.g. `10s`", + Optional: true, + }, + "indexing_slowlog_threshold_index_info": schema.StringAttribute{ + Description: "Set the cutoff for shard level slow search logging of slow searches for indexing queries, in time units, e.g. `5s`", + Optional: true, + }, + "indexing_slowlog_threshold_index_debug": schema.StringAttribute{ + Description: "Set the cutoff for shard level slow search logging of slow searches for indexing queries, in time units, e.g. `2s`", + Optional: true, + }, + "indexing_slowlog_threshold_index_trace": schema.StringAttribute{ + Description: "Set the cutoff for shard level slow search logging of slow searches for indexing queries, in time units, e.g. `500ms`", + Optional: true, + }, + "indexing_slowlog_level": schema.StringAttribute{ + Description: "Set which logging level to use for the search slow log, can be: `warn`, `info`, `debug`, `trace`", + Optional: true, + Validators: []validator.String{ + stringvalidator.OneOf("warn", "info", "debug", "trace"), + }, + }, + "indexing_slowlog_source": schema.StringAttribute{ + Description: "Set the number of characters of the `_source` to include in the slowlog lines, `false` or `0` will skip logging the source entirely and setting it to `true` will log the entire source regardless of size. The original `_source` is reformatted by default to make sure that it fits on a single log line.", + Optional: true, + }, + // To change analyzer setting, the index must be closed, updated, and then reopened but it can't be handled in terraform. + // We raise error when they are tried to be updated instead of setting ForceNew not to have unexpected deletion. + "analysis_analyzer": schema.StringAttribute{ + Description: "A JSON string describing the analyzers applied to the index.", + Optional: true, + CustomType: jsontypes.NormalizedType{}, + Validators: []validator.String{ + index.StringIsJSONObject{}, + }, + }, + "analysis_tokenizer": schema.StringAttribute{ + Description: "A JSON string describing the tokenizers applied to the index.", + Optional: true, + CustomType: jsontypes.NormalizedType{}, + Validators: []validator.String{ + index.StringIsJSONObject{}, + }, + }, + "analysis_char_filter": schema.StringAttribute{ + Description: "A JSON string describing the char_filters applied to the index.", + Optional: true, + CustomType: jsontypes.NormalizedType{}, + Validators: []validator.String{ + index.StringIsJSONObject{}, + }, + }, + "analysis_filter": schema.StringAttribute{ + Description: "A JSON string describing the filters applied to the index.", + Optional: true, + CustomType: jsontypes.NormalizedType{}, + Validators: []validator.String{ + index.StringIsJSONObject{}, + }, + }, + "analysis_normalizer": schema.StringAttribute{ + Description: "A JSON string describing the normalizers applied to the index.", + Optional: true, + CustomType: jsontypes.NormalizedType{}, + Validators: []validator.String{ + index.StringIsJSONObject{}, + }, + }, + "mappings": schema.StringAttribute{ + Description: `Mapping for fields in the index. + If specified, this mapping can include: field names, [field data types](https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-types.html), [mapping parameters](https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-params.html). + **NOTE:** + - Changing datatypes in the existing _mappings_ will force index to be re-created. + - Removing field will be ignored by default same as elasticsearch. You need to recreate the index to remove field completely. + `, + Optional: true, + Computed: true, + CustomType: jsontypes.NormalizedType{}, + Validators: []validator.String{ + index.StringIsJSONObject{}, + }, + Default: stringdefault.StaticString("{}"), + PlanModifiers: []planmodifier.String{ + mappingsPlanModifier{}, + }, + }, + "settings_raw": schema.StringAttribute{ + Description: "All raw settings fetched from the cluster.", + Computed: true, + CustomType: jsontypes.NormalizedType{}, + // TODO: Plan modifier. Use state if no other settings have been modified + }, + "deletion_protection": schema.BoolAttribute{ + Optional: true, + Computed: true, + Default: booldefault.StaticBool(true), + Description: "Whether to allow Terraform to destroy the index. Unless this field is set to false in Terraform state, a terraform destroy or terraform apply command that deletes the instance will fail.", + }, + "include_type_name": schema.BoolAttribute{ + Description: "If true, a mapping type is expected in the body of mappings. Defaults to false. Supported for Elasticsearch 7.x.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, + "wait_for_active_shards": schema.StringAttribute{ + Description: "The number of shard copies that must be active before proceeding with the operation. Set to `all` or any positive integer up to the total number of shards in the index (number_of_replicas+1). Default: `1`, the primary shard. This value is ignored when running against Serverless projects.", + Optional: true, + Computed: true, + Default: stringdefault.StaticString("1"), + }, + "master_timeout": schema.StringAttribute{ + Description: "Period to wait for a connection to the master node. If no response is received before the timeout expires, the request fails and returns an error. Defaults to `30s`. This value is ignored when running against Serverless projects.", + Optional: true, + Computed: true, + Default: stringdefault.StaticString("30s"), + CustomType: customtypes.DurationType{}, + }, + "timeout": schema.StringAttribute{ + Description: "Period to wait for a response. If no response is received before the timeout expires, the request fails and returns an error. Defaults to `30s`.", + Optional: true, + Computed: true, + Default: stringdefault.StaticString("30s"), + CustomType: customtypes.DurationType{}, + }, + }, + } +} + +func aliasElementType() attr.Type { + return getSchema().Blocks["alias"].Type().(attr.TypeWithElementType).ElementType() +} + +func settingsElementType() attr.Type { + return getSchema().Blocks["settings"].Type().(attr.TypeWithElementType).ElementType() +} + +func settingElementType() attr.Type { + return getSchema().Blocks["settings"].GetNestedObject().GetBlocks()["setting"].Type().(attr.TypeWithElementType).ElementType() +} diff --git a/internal/elasticsearch/index/index/update.go b/internal/elasticsearch/index/index/update.go new file mode 100644 index 000000000..26caaf8ca --- /dev/null +++ b/internal/elasticsearch/index/index/update.go @@ -0,0 +1,155 @@ +package index + +import ( + "context" + "maps" + + "github.com/elastic/terraform-provider-elasticstack/internal/clients" + "github.com/elastic/terraform-provider-elasticstack/internal/clients/elasticsearch" + "github.com/elastic/terraform-provider-elasticstack/internal/models" + "github.com/elastic/terraform-provider-elasticstack/internal/utils" + "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func (r *Resource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + if !r.resourceReady(&resp.Diagnostics) { + return + } + + var planModel tfModel + var stateModel tfModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &planModel)...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(req.State.Get(ctx, &stateModel)...) + if resp.Diagnostics.HasError() { + return + } + + client, diags := clients.MaybeNewApiClientFromFrameworkResource(ctx, planModel.ElasticsearchConnection, r.client) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + name := planModel.Name.ValueString() + id, sdkDiags := client.ID(ctx, name) + if sdkDiags.HasError() { + resp.Diagnostics.Append(utils.FrameworkDiagsFromSDK(sdkDiags)...) + return + } + + planModel.ID = types.StringValue(id.String()) + planApiModel, diags := planModel.toAPIModel(ctx) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + stateApiModel, diags := stateModel.toAPIModel(ctx) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if !planModel.Alias.Equal(stateModel.Alias) { + resp.Diagnostics.Append(r.updateAliases(ctx, client, name, planApiModel.Aliases, stateApiModel.Aliases)...) + if resp.Diagnostics.HasError() { + return + } + } + + resp.Diagnostics.Append(r.updateSettings(ctx, client, name, planApiModel.Settings, stateApiModel.Settings)...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(r.updateMappings(ctx, client, name, planModel.Mappings, stateModel.Mappings)...) + + finalModel, diags := readIndex(ctx, planModel, client) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, finalModel)...) +} + +func (r *Resource) updateAliases(ctx context.Context, client *clients.ApiClient, indexName string, planAliases map[string]models.IndexAlias, stateAliases map[string]models.IndexAlias) diag.Diagnostics { + aliasesToDelete := []string{} + for aliasName := range stateAliases { + if _, ok := planAliases[aliasName]; !ok { + aliasesToDelete = append(aliasesToDelete, aliasName) + } + } + + if len(aliasesToDelete) > 0 { + diags := elasticsearch.DeleteIndexAlias(ctx, client, indexName, aliasesToDelete) + if diags.HasError() { + return diags + } + } + + for _, alias := range planAliases { + diags := elasticsearch.UpdateIndexAlias(ctx, client, indexName, &alias) + if diags.HasError() { + return diags + } + } + + return nil +} + +func (r *Resource) updateSettings(ctx context.Context, client *clients.ApiClient, indexName string, planSettings map[string]interface{}, stateSettings map[string]interface{}) diag.Diagnostics { + planDynamicSettings := map[string]interface{}{} + stateDynamicSettings := map[string]interface{}{} + + for _, key := range dynamicSettingsKeys { + if planSetting, ok := planSettings[key]; ok { + planDynamicSettings[key] = planSetting + } + + if stateSetting, ok := stateSettings[key]; ok { + stateDynamicSettings[key] = stateSetting + } + } + + if !maps.Equal(planDynamicSettings, stateDynamicSettings) { + // Settings which are being removed must be explicitly set to null in the new settings + for setting := range stateDynamicSettings { + if _, ok := planDynamicSettings[setting]; !ok { + planDynamicSettings[setting] = nil + } + } + + diags := elasticsearch.UpdateIndexSettings(ctx, client, indexName, planDynamicSettings) + if diags.HasError() { + return diags + } + } + + return nil +} + +func (r *Resource) updateMappings(ctx context.Context, client *clients.ApiClient, indexName string, planMappings jsontypes.Normalized, stateMappings jsontypes.Normalized) diag.Diagnostics { + areEqual, diags := planMappings.StringSemanticEquals(ctx, stateMappings) + if diags.HasError() { + return diags + } + + if areEqual { + return nil + } + + diags = elasticsearch.UpdateIndexMappings(ctx, client, indexName, planMappings.ValueString()) + if diags.HasError() { + return diags + } + + return nil +} diff --git a/internal/elasticsearch/index/validation.go b/internal/elasticsearch/index/validation.go index 0d5d8601f..b2d23cd56 100644 --- a/internal/elasticsearch/index/validation.go +++ b/internal/elasticsearch/index/validation.go @@ -1,8 +1,11 @@ package index import ( + "context" "encoding/json" "fmt" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" ) func stringIsJSONObject(i interface{}, s string) (warnings []string, errors []error) { @@ -20,3 +23,29 @@ func stringIsJSONObject(i interface{}, s string) (warnings []string, errors []er return } + +type StringIsJSONObject struct{} + +func (s StringIsJSONObject) Description(_ context.Context) string { + return "Ensure that the attribute contains a valid JSON object, and not a simple value" +} + +func (s StringIsJSONObject) MarkdownDescription(ctx context.Context) string { + return s.Description(ctx) +} + +func (s StringIsJSONObject) ValidateString(_ context.Context, req validator.StringRequest, resp *validator.StringResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + m := map[string]interface{}{} + if err := json.Unmarshal([]byte(req.ConfigValue.ValueString()), &m); err != nil { + resp.Diagnostics.AddAttributeError( + req.Path, + "expected value to be a JSON object", + fmt.Sprintf("This value must be an object, not a simple type or array. Check the documentation for the expected format. %s", err), + ) + return + } +} diff --git a/internal/schema/connection.go b/internal/schema/connection.go index 6eac05e28..182f361f8 100644 --- a/internal/schema/connection.go +++ b/internal/schema/connection.go @@ -12,7 +12,7 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) -func GetEsFWConnectionBlock(keyName string) fwschema.Block { +func GetEsFWConnectionBlock(keyName string, isProviderConfiguration bool) fwschema.Block { usernamePath := path.MatchRelative().AtParent().AtName("username") passwordPath := path.MatchRelative().AtParent().AtName("password") apiKeyPath := path.MatchRelative().AtParent().AtName("api_key") @@ -27,6 +27,7 @@ func GetEsFWConnectionBlock(keyName string) fwschema.Block { return fwschema.ListNestedBlock{ MarkdownDescription: "Elasticsearch connection configuration block. ", Description: "Elasticsearch connection configuration block. ", + DeprecationMessage: getDeprecationMessage(isProviderConfiguration), NestedObject: fwschema.NestedBlockObject{ Attributes: map[string]fwschema.Attribute{ "username": fwschema.StringAttribute{ diff --git a/internal/utils/customtypes/duration_type.go b/internal/utils/customtypes/duration_type.go new file mode 100644 index 000000000..fbc2d5952 --- /dev/null +++ b/internal/utils/customtypes/duration_type.go @@ -0,0 +1,68 @@ +package customtypes + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +var ( + _ basetypes.StringTypable = (*DurationType)(nil) +) + +type DurationType struct { + basetypes.StringType +} + +// String returns a human readable string of the type name. +func (t DurationType) String() string { + return "customtypes.DurationType" +} + +// ValueType returns the Value type. +func (t DurationType) ValueType(ctx context.Context) attr.Value { + return Duration{} +} + +// Equal returns true if the given type is equivalent. +func (t DurationType) Equal(o attr.Type) bool { + other, ok := o.(DurationType) + + if !ok { + return false + } + + return t.StringType.Equal(other.StringType) +} + +// ValueFromString returns a StringValuable type given a StringValue. +func (t DurationType) ValueFromString(ctx context.Context, in basetypes.StringValue) (basetypes.StringValuable, diag.Diagnostics) { + return Duration{ + StringValue: in, + }, nil +} + +// ValueFromTerraform returns a Value given a tftypes.Value. This is meant to convert the tftypes.Value into a more convenient Go type +// for the provider to consume the data with. +func (t DurationType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (attr.Value, error) { + attrValue, err := t.StringType.ValueFromTerraform(ctx, in) + if err != nil { + return nil, err + } + + stringValue, ok := attrValue.(basetypes.StringValue) + if !ok { + return nil, fmt.Errorf("unexpected value type of %T", attrValue) + } + + stringValuable, diags := t.ValueFromString(ctx, stringValue) + if diags.HasError() { + return nil, fmt.Errorf("unexpected error converting StringValue to StringValuable: %v", diags) + } + + return stringValuable, nil +} diff --git a/internal/utils/customtypes/duration_type_test.go b/internal/utils/customtypes/duration_type_test.go new file mode 100644 index 000000000..8076fa953 --- /dev/null +++ b/internal/utils/customtypes/duration_type_test.go @@ -0,0 +1,59 @@ +package customtypes + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/stretchr/testify/require" +) + +func TestDurationType_ValueType(t *testing.T) { + require.Equal(t, Duration{}, DurationType{}.ValueType(context.Background())) +} + +func TestDurationType_ValueFromString(t *testing.T) { + stringValue := basetypes.NewStringValue("duration") + expectedResult := Duration{StringValue: stringValue} + durationValue, diags := DurationType{}.ValueFromString(context.Background(), stringValue) + + require.Nil(t, diags) + require.Equal(t, expectedResult, durationValue) +} + +func TestDurationType_ValueFromTerraform(t *testing.T) { + tests := []struct { + name string + tfValue tftypes.Value + expectedValue attr.Value + expectedError string + }{ + { + name: "should return an error if the tf value is not a string", + tfValue: tftypes.NewValue(tftypes.Bool, true), + expectedValue: nil, + expectedError: "expected string", + }, + { + name: "should return a new duration value if the tf value is a string", + tfValue: tftypes.NewValue(tftypes.String, "3h"), + expectedValue: NewDurationValue("3h"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + val, err := DurationType{}.ValueFromTerraform(context.Background(), tt.tfValue) + + if tt.expectedError != "" { + require.ErrorContains(t, err, tt.expectedError) + } else { + require.Nil(t, err) + } + + require.Equal(t, tt.expectedValue, val) + }) + } +} diff --git a/internal/utils/customtypes/duration_value.go b/internal/utils/customtypes/duration_value.go new file mode 100644 index 000000000..2096db4e1 --- /dev/null +++ b/internal/utils/customtypes/duration_value.go @@ -0,0 +1,142 @@ +package customtypes + +import ( + "context" + "fmt" + "time" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/attr/xattr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +var ( + _ basetypes.StringValuable = (*Duration)(nil) + _ basetypes.StringValuableWithSemanticEquals = (*Duration)(nil) + _ xattr.ValidateableAttribute = (*Duration)(nil) +) + +type Duration struct { + basetypes.StringValue +} + +// Type returns a DurationType. +func (v Duration) Type(_ context.Context) attr.Type { + return DurationType{} +} + +// Equal returns true if the given value is equivalent. +func (v Duration) Equal(o attr.Value) bool { + other, ok := o.(Duration) + + if !ok { + return false + } + + return v.StringValue.Equal(other.StringValue) +} + +func (t Duration) ValidateAttribute(ctx context.Context, req xattr.ValidateAttributeRequest, resp *xattr.ValidateAttributeResponse) { + if t.IsNull() || t.IsUnknown() { + return + } + + valueString := t.ValueString() + if _, err := time.ParseDuration(valueString); err != nil { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Duration string value", + fmt.Sprintf(`A string value was provided that is not a valid Go duration\n\nGiven value "%s"\n`, valueString), + ) + } +} + +// StringSemanticEquals returns true if the given duration string value is semantically equal to the current duration string value. +// When compared, the durations are parsed into a time.Duration and the underlying nanosecond values compared. +func (v Duration) StringSemanticEquals(_ context.Context, newValuable basetypes.StringValuable) (bool, diag.Diagnostics) { + var diags diag.Diagnostics + + newValue, ok := newValuable.(Duration) + if !ok { + diags.AddError( + "Semantic Equality Check Error", + "An unexpected value type was received while performing semantic equality checks. "+ + "Please report this to the provider developers.\n\n"+ + "Expected Value Type: "+fmt.Sprintf("%T", v)+"\n"+ + "Got Value Type: "+fmt.Sprintf("%T", newValuable), + ) + + return false, diags + } + + if v.IsNull() { + return newValue.IsNull(), diags + } + + if v.IsUnknown() { + return newValue.IsUnknown(), diags + } + + vParsed, diags := v.Parse() + if diags.HasError() { + return false, diags + } + + newParsed, diags := newValue.Parse() + if diags.HasError() { + return false, diags + } + + return vParsed == newParsed, diags +} + +// Parse calls time.ParseDuration with the Duration StringValue. A null or unknown value will produce an error diagnostic. +func (v Duration) Parse() (time.Duration, diag.Diagnostics) { + var diags diag.Diagnostics + + if v.IsNull() { + diags.Append(diag.NewErrorDiagnostic("Duration Parse error", "duration string value is null")) + return 0, diags + } + + if v.IsUnknown() { + diags.Append(diag.NewErrorDiagnostic("Duration Parse Error", "duration string value is unknown")) + return 0, diags + } + + duration, err := time.ParseDuration(v.ValueString()) + if err != nil { + diags.Append(diag.NewErrorDiagnostic("Duration Parse Error", err.Error())) + } + + return duration, diags +} + +// NewDurationNull creates a Duration with a null value. Determine whether the value is null via IsNull method. +func NewDurationNull() Duration { + return Duration{ + StringValue: basetypes.NewStringNull(), + } +} + +// NewDurationUnknown creates a Duration with an unknown value. Determine whether the value is unknown via IsUnknown method. +func NewDurationUnknown() Duration { + return Duration{ + StringValue: basetypes.NewStringUnknown(), + } +} + +// NewDurationValue creates a Duration with a known value. Access the value via ValueString method. +func NewDurationValue(value string) Duration { + return Duration{ + StringValue: basetypes.NewStringValue(value), + } +} + +// NewDurationPointerValue creates a Duration with a null value if nil or a known value. Access the value via ValueStringPointer method. +func NewDurationPointerValue(value *string) Duration { + return Duration{ + StringValue: basetypes.NewStringPointerValue(value), + } +} diff --git a/internal/utils/customtypes/duration_value_test.go b/internal/utils/customtypes/duration_value_test.go new file mode 100644 index 000000000..b21f1dbc0 --- /dev/null +++ b/internal/utils/customtypes/duration_value_test.go @@ -0,0 +1,192 @@ +package customtypes + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/attr/xattr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/stretchr/testify/require" +) + +func TestDuration_Type(t *testing.T) { + require.Equal(t, DurationType{}, Duration{}.Type(context.Background())) +} + +func TestDuration_Equal(t *testing.T) { + tests := []struct { + name string + expectedEqual bool + val Duration + otherVal attr.Value + }{ + { + name: "not equal if the other value is not a duration", + expectedEqual: false, + val: NewDurationValue("3h"), + otherVal: basetypes.NewBoolValue(true), + }, + { + name: "not equal if the durations are not equal", + expectedEqual: false, + val: NewDurationValue("3h"), + otherVal: NewDurationValue("1m"), + }, + { + name: "not equal if the durations are semantically equal but string values are not equal", + expectedEqual: false, + val: NewDurationValue("60s"), + otherVal: NewDurationValue("1m"), + }, + { + name: "equal if the duration string values are equal", + expectedEqual: true, + val: NewDurationValue("3h"), + otherVal: NewDurationValue("3h"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.expectedEqual, tt.val.Equal(tt.otherVal)) + }) + } +} + +func TestDuration_ValidateAttribute(t *testing.T) { + tests := []struct { + name string + duration Duration + expectedDiags diag.Diagnostics + }{ + { + name: "unknown is valid", + duration: NewDurationNull(), + }, + { + name: "null is valid", + duration: NewDurationUnknown(), + }, + { + name: "valid durations are valid", + duration: NewDurationValue("3h"), + }, + { + name: "non-duration strings are invalid", + duration: NewDurationValue("not a duration"), + expectedDiags: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("duration"), + "Invalid Duration string value", + `A string value was provided that is not a valid Go duration\n\nGiven value "not a duration"\n`, + ), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp := xattr.ValidateAttributeResponse{} + + tt.duration.ValidateAttribute( + context.Background(), + xattr.ValidateAttributeRequest{ + Path: path.Root("duration"), + }, + &resp, + ) + + if tt.expectedDiags == nil { + require.Nil(t, resp.Diagnostics) + } else { + require.Equal(t, tt.expectedDiags, resp.Diagnostics) + } + }) + } +} + +func TestDuration_StringSemanticEquals(t *testing.T) { + tests := []struct { + name string + duration Duration + otherVal basetypes.StringValuable + expectedEqual bool + expectedErrorDiags bool + }{ + { + name: "should error if the other value is not a duration", + duration: NewDurationValue("3h"), + otherVal: basetypes.NewStringValue("3d"), + expectedEqual: false, + expectedErrorDiags: true, + }, + { + name: "two null values are semantically equal", + duration: NewDurationNull(), + otherVal: NewDurationNull(), + expectedEqual: true, + }, + { + name: "null is not equal to unknown", + duration: NewDurationNull(), + otherVal: NewDurationUnknown(), + expectedEqual: false, + }, + { + name: "null is not equal to a string value", + duration: NewDurationNull(), + otherVal: NewDurationValue("3h"), + expectedEqual: false, + }, + { + name: "two unknown values are semantically equal", + duration: NewDurationUnknown(), + otherVal: NewDurationUnknown(), + expectedEqual: true, + }, + { + name: "unknown is not equal to a string value", + duration: NewDurationUnknown(), + otherVal: NewDurationValue("3h"), + expectedEqual: false, + }, + { + name: "two equal values are semantically equal", + duration: NewDurationValue("3h"), + otherVal: NewDurationValue("3h"), + expectedEqual: true, + }, + { + name: "two semantically equal values are semantically equal", + duration: NewDurationValue("3h"), + otherVal: NewDurationValue("180m"), + expectedEqual: true, + }, + { + name: "errors if this value is invalid", + duration: NewDurationValue("not a duration"), + otherVal: NewDurationValue("180m"), + expectedEqual: false, + expectedErrorDiags: true, + }, + { + name: "errors if the other value is invalid", + duration: NewDurationValue("3h"), + otherVal: NewDurationValue("not a duration"), + expectedEqual: false, + expectedErrorDiags: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isEqual, diags := tt.duration.StringSemanticEquals(context.Background(), tt.otherVal) + + require.Equal(t, tt.expectedEqual, isEqual) + require.Equal(t, tt.expectedErrorDiags, diags.HasError()) + }) + } +} diff --git a/internal/utils/schema.go b/internal/utils/schema.go index 855e824a2..6bc57c00f 100644 --- a/internal/utils/schema.go +++ b/internal/utils/schema.go @@ -13,6 +13,6 @@ func ExpandStringSet(set *schema.Set) []string { return strs } -func IsKnown(val attr.Value) bool { - return !(val.IsNull() || val.IsUnknown()) +func IsKnown(value attr.Value) bool { + return !value.IsNull() && !value.IsUnknown() } diff --git a/internal/utils/utils.go b/internal/utils/utils.go index 81a3d977c..9801df9ea 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -70,6 +70,24 @@ func CheckHttpErrorFromFW(res *http.Response, errMsg string) fwdiag.Diagnostics return diags } +func FrameworkDiagsFromSDK(sdkDiags sdkdiag.Diagnostics) fwdiag.Diagnostics { + var diags fwdiag.Diagnostics + + for _, sdkDiag := range sdkDiags { + var fwDiag fwdiag.Diagnostic + + if sdkDiag.Severity == sdkdiag.Error { + fwDiag = fwdiag.NewErrorDiagnostic(sdkDiag.Summary, sdkDiag.Detail) + } else { + fwDiag = fwdiag.NewWarningDiagnostic(sdkDiag.Summary, sdkDiag.Detail) + } + + diags.Append(fwDiag) + } + + return diags +} + // Compares the JSON in two byte slices func JSONBytesEqual(a, b []byte) (bool, error) { var j, j2 interface{} diff --git a/provider/plugin_framework.go b/provider/plugin_framework.go index 305d81324..2d15aa0b2 100644 --- a/provider/plugin_framework.go +++ b/provider/plugin_framework.go @@ -5,6 +5,7 @@ import ( "github.com/elastic/terraform-provider-elasticstack/internal/clients" "github.com/elastic/terraform-provider-elasticstack/internal/clients/config" + "github.com/elastic/terraform-provider-elasticstack/internal/elasticsearch/index/index" "github.com/elastic/terraform-provider-elasticstack/internal/kibana/data_view" "github.com/elastic/terraform-provider-elasticstack/internal/kibana/import_saved_objects" "github.com/elastic/terraform-provider-elasticstack/internal/kibana/spaces" @@ -41,7 +42,7 @@ func (p *Provider) Metadata(_ context.Context, _ fwprovider.MetadataRequest, res 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), + esKeyName: schema.GetEsFWConnectionBlock(esKeyName, true), kbKeyName: schema.GetKbFWConnectionBlock(), fleetKeyName: schema.GetFleetFWConnectionBlock(), }, @@ -77,6 +78,7 @@ func (p *Provider) Resources(ctx context.Context) []func() resource.Resource { func() resource.Resource { return &import_saved_objects.Resource{} }, func() resource.Resource { return &data_view.Resource{} }, func() resource.Resource { return &private_location.Resource{} }, + func() resource.Resource { return &index.Resource{} }, func() resource.Resource { return &synthetics.Resource{} }, } } diff --git a/provider/provider.go b/provider/provider.go index 18145367c..f2676b481 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -89,7 +89,6 @@ func New(version string) *schema.Provider { "elasticstack_elasticsearch_cluster_settings": cluster.ResourceSettings(), "elasticstack_elasticsearch_component_template": index.ResourceComponentTemplate(), "elasticstack_elasticsearch_data_stream": index.ResourceDataStream(), - "elasticstack_elasticsearch_index": index.ResourceIndex(), "elasticstack_elasticsearch_index_lifecycle": index.ResourceIlm(), "elasticstack_elasticsearch_index_template": index.ResourceTemplate(), "elasticstack_elasticsearch_ingest_pipeline": ingest.ResourceIngestPipeline(),