diff --git a/docs/resources/elasticsearch_index.md b/docs/resources/elasticsearch_index.md index 15e5f3377..16c79eff8 100644 --- a/docs/resources/elasticsearch_index.md +++ b/docs/resources/elasticsearch_index.md @@ -21,16 +21,14 @@ provider "elasticstack" { resource "elasticstack_elasticsearch_index" "my_index" { name = "my-index" - alias { + alias = [{ name = "my_alias_1" - } - - alias { + }, { name = "my_alias_2" filter = jsonencode({ term = { "user.id" = "developer" } }) - } + }] mappings = jsonencode({ properties = { @@ -60,7 +58,7 @@ resource "elasticstack_elasticsearch_index" "my_index" { ### Optional -- `alias` (Block Set) Aliases for the index. (see [below for nested schema](#nestedblock--alias)) +- `alias` (Attributes Set) Aliases for the index. (see [below for nested schema](#nestedatt--alias)) - `analysis_analyzer` (String) A JSON string describing the analyzers applied to the index. - `analysis_char_filter` (String) A JSON string describing the char_filters applied to the index. - `analysis_filter` (String) A JSON string describing the filters applied to the index. @@ -136,7 +134,7 @@ resource "elasticstack_elasticsearch_index" "my_index" { - `id` (String) Internal identifier of the resource - `settings_raw` (String) All raw settings fetched from the cluster. - + ### Nested Schema for `alias` Required: diff --git a/docs/resources/elasticsearch_index_alias.md b/docs/resources/elasticsearch_index_alias.md new file mode 100644 index 000000000..efb755569 --- /dev/null +++ b/docs/resources/elasticsearch_index_alias.md @@ -0,0 +1,61 @@ + +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "elasticstack_elasticsearch_index_alias Resource - terraform-provider-elasticstack" +subcategory: "Index" +description: |- + Manages an Elasticsearch alias. See the alias documentation https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-aliases.html for more details. +--- + +# elasticstack_elasticsearch_index_alias (Resource) + +Manages an Elasticsearch alias. See the [alias documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-aliases.html) for more details. + + + + +## Schema + +### Required + +- `name` (String) The alias name. + +### Optional + +- `read_indices` (Attributes Set) Set of read indices for the alias. (see [below for nested schema](#nestedatt--read_indices)) +- `write_index` (Attributes) The write index for the alias. Only one write index is allowed per alias. (see [below for nested schema](#nestedatt--write_index)) + +### Read-Only + +- `id` (String) Generated ID of the alias resource. + + +### Nested Schema for `read_indices` + +Required: + +- `name` (String) Name of the read index. + +Optional: + +- `filter` (String) Query used to limit documents the alias can access. +- `index_routing` (String) Value used to route indexing operations to a specific shard. +- `is_hidden` (Boolean) If true, the alias is hidden. +- `routing` (String) Value used to route indexing and search operations to a specific shard. +- `search_routing` (String) Value used to route search operations to a specific shard. + + + +### Nested Schema for `write_index` + +Required: + +- `name` (String) Name of the write index. + +Optional: + +- `filter` (String) Query used to limit documents the alias can access. +- `index_routing` (String) Value used to route indexing operations to a specific shard. +- `is_hidden` (Boolean) If true, the alias is hidden. +- `routing` (String) Value used to route indexing and search operations to a specific shard. +- `search_routing` (String) Value used to route search operations to a specific shard. diff --git a/examples/resources/elasticstack_elasticsearch_index/resource.tf b/examples/resources/elasticstack_elasticsearch_index/resource.tf index 3b5801672..ce3d3bb25 100644 --- a/examples/resources/elasticstack_elasticsearch_index/resource.tf +++ b/examples/resources/elasticstack_elasticsearch_index/resource.tf @@ -5,16 +5,14 @@ provider "elasticstack" { resource "elasticstack_elasticsearch_index" "my_index" { name = "my-index" - alias { + alias = [{ name = "my_alias_1" - } - - alias { + }, { name = "my_alias_2" filter = jsonencode({ term = { "user.id" = "developer" } }) - } + }] mappings = jsonencode({ properties = { diff --git a/internal/clients/elasticsearch/index.go b/internal/clients/elasticsearch/index.go index 3ce52078c..32fdc5ac0 100644 --- a/internal/clients/elasticsearch/index.go +++ b/internal/clients/elasticsearch/index.go @@ -581,6 +581,133 @@ func DeleteDataStreamLifecycle(ctx context.Context, apiClient *clients.ApiClient return nil } +func GetAlias(ctx context.Context, apiClient *clients.ApiClient, aliasName string) (map[string]models.Index, fwdiags.Diagnostics) { + esClient, err := apiClient.GetESClient() + if err != nil { + return nil, fwdiags.Diagnostics{ + fwdiags.NewErrorDiagnostic(err.Error(), err.Error()), + } + } + + res, err := esClient.Indices.GetAlias( + esClient.Indices.GetAlias.WithName(aliasName), + esClient.Indices.GetAlias.WithContext(ctx), + ) + if err != nil { + return nil, fwdiags.Diagnostics{ + fwdiags.NewErrorDiagnostic(err.Error(), err.Error()), + } + } + defer res.Body.Close() + + if res.StatusCode == http.StatusNotFound { + return nil, nil + } + + diags := diagutil.CheckErrorFromFW(res, fmt.Sprintf("Unable to get alias '%s'", aliasName)) + if diags.HasError() { + return nil, diags + } + + indices := make(map[string]models.Index) + if err := json.NewDecoder(res.Body).Decode(&indices); err != nil { + return nil, fwdiags.Diagnostics{ + fwdiags.NewErrorDiagnostic(err.Error(), err.Error()), + } + } + + return indices, nil +} + +// AliasAction represents a single action in an atomic alias update operation +type AliasAction struct { + Type string // "add" or "remove" + Index string + Alias string + IsWriteIndex bool + Filter map[string]interface{} + IndexRouting string + IsHidden bool + Routing string + SearchRouting string +} + +// UpdateAliasesAtomic performs atomic alias updates using multiple actions +func UpdateAliasesAtomic(ctx context.Context, apiClient *clients.ApiClient, actions []AliasAction) fwdiags.Diagnostics { + esClient, err := apiClient.GetESClient() + if err != nil { + return fwdiags.Diagnostics{ + fwdiags.NewErrorDiagnostic(err.Error(), err.Error()), + } + } + + var aliasActions []map[string]interface{} + + for _, action := range actions { + switch action.Type { + case "remove": + aliasActions = append(aliasActions, map[string]interface{}{ + "remove": map[string]interface{}{ + "index": action.Index, + "alias": action.Alias, + }, + }) + case "add": + addDetails := map[string]interface{}{ + "index": action.Index, + "alias": action.Alias, + } + + if action.IsWriteIndex { + addDetails["is_write_index"] = true + } + if action.Filter != nil { + addDetails["filter"] = action.Filter + } + if action.IndexRouting != "" { + addDetails["index_routing"] = action.IndexRouting + } + if action.SearchRouting != "" { + addDetails["search_routing"] = action.SearchRouting + } + if action.Routing != "" { + addDetails["routing"] = action.Routing + } + if action.IsHidden { + addDetails["is_hidden"] = action.IsHidden + } + + aliasActions = append(aliasActions, map[string]interface{}{ + "add": addDetails, + }) + } + } + + requestBody := map[string]interface{}{ + "actions": aliasActions, + } + + aliasBytes, err := json.Marshal(requestBody) + if err != nil { + return fwdiags.Diagnostics{ + fwdiags.NewErrorDiagnostic(err.Error(), err.Error()), + } + } + + res, err := esClient.Indices.UpdateAliases( + bytes.NewReader(aliasBytes), + esClient.Indices.UpdateAliases.WithContext(ctx), + ) + if err != nil { + return fwdiags.Diagnostics{ + fwdiags.NewErrorDiagnostic(err.Error(), err.Error()), + } + } + defer res.Body.Close() + + return diagutil.CheckErrorFromFW(res, "Unable to update aliases atomically") +} + func PutIngestPipeline(ctx context.Context, apiClient *clients.ApiClient, pipeline *models.IngestPipeline) diag.Diagnostics { var diags diag.Diagnostics pipelineBytes, err := json.Marshal(pipeline) diff --git a/internal/elasticsearch/index/alias/acc_test.go b/internal/elasticsearch/index/alias/acc_test.go new file mode 100644 index 000000000..a3e72bb48 --- /dev/null +++ b/internal/elasticsearch/index/alias/acc_test.go @@ -0,0 +1,621 @@ +package alias_test + +import ( + "fmt" + "testing" + + "github.com/elastic/terraform-provider-elasticstack/internal/acctest" + "github.com/elastic/terraform-provider-elasticstack/internal/clients" + "github.com/hashicorp/terraform-plugin-testing/config" + sdkacctest "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" +) + +func TestAccResourceAlias(t *testing.T) { + // generate random names + aliasName := sdkacctest.RandStringFromCharSet(22, sdkacctest.CharSetAlpha) + indexName := sdkacctest.RandStringFromCharSet(22, sdkacctest.CharSetAlpha) + indexName2 := sdkacctest.RandStringFromCharSet(22, sdkacctest.CharSetAlpha) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(t) + }, + CheckDestroy: checkResourceAliasDestroy, + ProtoV6ProviderFactories: acctest.Providers, + Steps: []resource.TestStep{ + { + Config: testAccResourceAliasCreateDirect, + ConfigVariables: map[string]config.Variable{ + "alias_name": config.StringVariable(aliasName), + "index_name": config.StringVariable(indexName), + "index_name2": config.StringVariable(indexName2), + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index_alias.test_alias", "name", aliasName), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index_alias.test_alias", "write_index.name", indexName), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index_alias.test_alias", "write_index.is_hidden", "false"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index_alias.test_alias", "read_indices.#", "0"), + ), + }, + { + Config: testAccResourceAliasUpdateDirect, + ConfigVariables: map[string]config.Variable{ + "alias_name": config.StringVariable(aliasName), + "index_name": config.StringVariable(indexName), + "index_name2": config.StringVariable(indexName2), + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index_alias.test_alias", "name", aliasName), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index_alias.test_alias", "write_index.name", indexName2), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index_alias.test_alias", "read_indices.#", "1"), + ), + }, + { + Config: testAccResourceAliasWithFilterDirect, + ConfigVariables: map[string]config.Variable{ + "alias_name": config.StringVariable(aliasName), + "index_name": config.StringVariable(indexName), + "index_name2": config.StringVariable(indexName2), + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index_alias.test_alias", "name", aliasName), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index_alias.test_alias", "write_index.name", indexName), + resource.TestCheckResourceAttrSet("elasticstack_elasticsearch_index_alias.test_alias", "write_index.filter"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index_alias.test_alias", "write_index.index_routing", "write-routing"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index_alias.test_alias", "read_indices.#", "1"), + ), + }, + }, + }) +} + +func TestAccResourceAliasWriteIndex(t *testing.T) { + // generate random names + aliasName := sdkacctest.RandStringFromCharSet(22, sdkacctest.CharSetAlpha) + indexName1 := sdkacctest.RandStringFromCharSet(22, sdkacctest.CharSetAlpha) + indexName2 := sdkacctest.RandStringFromCharSet(22, sdkacctest.CharSetAlpha) + indexName3 := sdkacctest.RandStringFromCharSet(22, sdkacctest.CharSetAlpha) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(t) + }, + CheckDestroy: checkResourceAliasDestroy, + ProtoV6ProviderFactories: acctest.Providers, + Steps: []resource.TestStep{ + // Case 1: Single index with is_write_index=true + { + Config: testAccResourceAliasWriteIndexSingleDirect, + ConfigVariables: map[string]config.Variable{ + "alias_name": config.StringVariable(aliasName), + "index_name1": config.StringVariable(indexName1), + "index_name2": config.StringVariable(indexName2), + "index_name3": config.StringVariable(indexName3), + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index_alias.test_alias", "name", aliasName), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index_alias.test_alias", "write_index.name", indexName1), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index_alias.test_alias", "read_indices.#", "0"), + ), + }, + // Case 2: Add new index with is_write_index=true, existing becomes read index + { + Config: testAccResourceAliasWriteIndexSwitchDirect, + ConfigVariables: map[string]config.Variable{ + "alias_name": config.StringVariable(aliasName), + "index_name1": config.StringVariable(indexName1), + "index_name2": config.StringVariable(indexName2), + "index_name3": config.StringVariable(indexName3), + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index_alias.test_alias", "name", aliasName), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index_alias.test_alias", "write_index.name", indexName2), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index_alias.test_alias", "read_indices.#", "1"), + ), + }, + // Case 3: Add third index as write index + { + Config: testAccResourceAliasWriteIndexTripleDirect, + ConfigVariables: map[string]config.Variable{ + "alias_name": config.StringVariable(aliasName), + "index_name1": config.StringVariable(indexName1), + "index_name2": config.StringVariable(indexName2), + "index_name3": config.StringVariable(indexName3), + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index_alias.test_alias", "name", aliasName), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index_alias.test_alias", "write_index.name", indexName3), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index_alias.test_alias", "read_indices.#", "2"), + ), + }, + // Case 4: Remove initial index, keep two indices with one as write index + { + Config: testAccResourceAliasWriteIndexRemoveFirstDirect, + ConfigVariables: map[string]config.Variable{ + "alias_name": config.StringVariable(aliasName), + "index_name1": config.StringVariable(indexName1), + "index_name2": config.StringVariable(indexName2), + "index_name3": config.StringVariable(indexName3), + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index_alias.test_alias", "name", aliasName), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index_alias.test_alias", "write_index.name", indexName3), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index_alias.test_alias", "read_indices.#", "1"), + ), + }, + }, + }) +} + +func TestAccResourceAliasDataStream(t *testing.T) { + // generate random names + aliasName := sdkacctest.RandStringFromCharSet(22, sdkacctest.CharSetAlpha) + dsName := sdkacctest.RandStringFromCharSet(22, sdkacctest.CharSetAlpha) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + CheckDestroy: checkResourceAliasDestroy, + ProtoV6ProviderFactories: acctest.Providers, + Steps: []resource.TestStep{ + { + Config: testAccResourceAliasDataStreamCreate, + ConfigVariables: map[string]config.Variable{ + "alias_name": config.StringVariable(aliasName), + "ds_name": config.StringVariable(dsName), + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index_alias.test_alias", "name", aliasName), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index_alias.test_alias", "write_index.name", dsName), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_index_alias.test_alias", "read_indices.#", "0"), + ), + }, + }, + }) +} + +const testAccResourceAliasDataStreamCreate = ` +variable "alias_name" { + description = "The alias name" + type = string +} + +variable "ds_name" { + description = "The data stream name" + type = string +} + +provider "elasticstack" { + elasticsearch {} +} + +resource "elasticstack_elasticsearch_index_template" "test_ds_template" { + name = var.ds_name + index_patterns = [var.ds_name] + data_stream {} +} + +resource "elasticstack_elasticsearch_data_stream" "test_ds" { + name = var.ds_name + depends_on = [ + elasticstack_elasticsearch_index_template.test_ds_template + ] +} + +resource "elasticstack_elasticsearch_index_alias" "test_alias" { + name = var.alias_name + + write_index = { + name = elasticstack_elasticsearch_data_stream.test_ds.name + } +} +` + +func checkResourceAliasDestroy(s *terraform.State) error { + client, err := clients.NewAcceptanceTestingClient() + if err != nil { + return err + } + + for _, rs := range s.RootModule().Resources { + if rs.Type != "elasticstack_elasticsearch_index_alias" { + continue + } + + // Handle the case where ID might not be in the expected format + aliasName := rs.Primary.ID + if compId, err := clients.CompositeIdFromStr(rs.Primary.ID); err == nil { + aliasName = compId.ResourceId + } + + esClient, err := client.GetESClient() + if err != nil { + return err + } + + res, err := esClient.Indices.GetAlias( + esClient.Indices.GetAlias.WithName(aliasName), + ) + if err != nil { + return err + } + defer res.Body.Close() + + if res.StatusCode != 404 { + return fmt.Errorf("Alias (%s) still exists", aliasName) + } + } + return nil +} + +const testAccResourceAliasCreateDirect = ` +variable "alias_name" { + description = "The alias name" + type = string +} + +variable "index_name" { + description = "The index name" + type = string +} + +variable "index_name2" { + description = "The second index name" + type = string +} + +provider "elasticstack" { + elasticsearch {} +} + +resource "elasticstack_elasticsearch_index" "index1" { + name = var.index_name + deletion_protection = false + lifecycle { + ignore_changes = [settings_raw] + } +} + +resource "elasticstack_elasticsearch_index_alias" "test_alias" { + name = var.alias_name + + write_index = { + name = elasticstack_elasticsearch_index.index1.name + } +} +` + +const testAccResourceAliasUpdateDirect = ` +variable "alias_name" { + description = "The alias name" + type = string +} + +variable "index_name" { + description = "The index name" + type = string +} + +variable "index_name2" { + description = "The second index name" + type = string +} + +provider "elasticstack" { + elasticsearch {} +} + +resource "elasticstack_elasticsearch_index" "index1" { + name = var.index_name + deletion_protection = false + lifecycle { + ignore_changes = [settings_raw] + } +} + +resource "elasticstack_elasticsearch_index" "index2" { + name = var.index_name2 + deletion_protection = false + lifecycle { + ignore_changes = [settings_raw] + } +} + +resource "elasticstack_elasticsearch_index_alias" "test_alias" { + name = var.alias_name + + write_index = { + name = elasticstack_elasticsearch_index.index2.name + } + + read_indices = [{ + name = elasticstack_elasticsearch_index.index1.name + }] +} +` + +const testAccResourceAliasWithFilterDirect = ` +variable "alias_name" { + description = "The alias name" + type = string +} + +variable "index_name" { + description = "The index name" + type = string +} + +variable "index_name2" { + description = "The second index name" + type = string +} + +provider "elasticstack" { + elasticsearch {} +} + +resource "elasticstack_elasticsearch_index" "index1" { + name = var.index_name + deletion_protection = false + lifecycle { + ignore_changes = [settings_raw] + } +} + +resource "elasticstack_elasticsearch_index" "index2" { + name = var.index_name2 + deletion_protection = false + lifecycle { + ignore_changes = [settings_raw] + } +} + +resource "elasticstack_elasticsearch_index_alias" "test_alias" { + name = var.alias_name + + write_index = { + name = elasticstack_elasticsearch_index.index1.name + index_routing = "write-routing" + filter = jsonencode({ + term = { + status = "published" + } + }) + } + + read_indices = [{ + name = elasticstack_elasticsearch_index.index2.name + filter = jsonencode({ + term = { + status = "draft" + } + }) + }] +} +` + +const testAccResourceAliasWriteIndexSingleDirect = ` +variable "alias_name" { + description = "The alias name" + type = string +} + +variable "index_name1" { + description = "The first index name" + type = string +} + +variable "index_name2" { + description = "The second index name" + type = string +} + +variable "index_name3" { + description = "The third index name" + type = string +} + +provider "elasticstack" { + elasticsearch {} +} + +resource "elasticstack_elasticsearch_index" "index1" { + name = var.index_name1 + deletion_protection = false + lifecycle { + ignore_changes = [settings_raw] + } +} + +resource "elasticstack_elasticsearch_index_alias" "test_alias" { + name = var.alias_name + + write_index = { + name = elasticstack_elasticsearch_index.index1.name + } +} +` + +const testAccResourceAliasWriteIndexSwitchDirect = ` +variable "alias_name" { + description = "The alias name" + type = string +} + +variable "index_name1" { + description = "The first index name" + type = string +} + +variable "index_name2" { + description = "The second index name" + type = string +} + +variable "index_name3" { + description = "The third index name" + type = string +} + +provider "elasticstack" { + elasticsearch {} +} + +resource "elasticstack_elasticsearch_index" "index1" { + name = var.index_name1 + deletion_protection = false + lifecycle { + ignore_changes = [settings_raw] + } +} + +resource "elasticstack_elasticsearch_index" "index2" { + name = var.index_name2 + deletion_protection = false + lifecycle { + ignore_changes = [settings_raw] + } +} + +resource "elasticstack_elasticsearch_index_alias" "test_alias" { + name = var.alias_name + + write_index = { + name = elasticstack_elasticsearch_index.index2.name + } + + read_indices = [{ + name = elasticstack_elasticsearch_index.index1.name + }] +} +` + +const testAccResourceAliasWriteIndexTripleDirect = ` +variable "alias_name" { + description = "The alias name" + type = string +} + +variable "index_name1" { + description = "The first index name" + type = string +} + +variable "index_name2" { + description = "The second index name" + type = string +} + +variable "index_name3" { + description = "The third index name" + type = string +} + +provider "elasticstack" { + elasticsearch {} +} + +resource "elasticstack_elasticsearch_index" "index1" { + name = var.index_name1 + deletion_protection = false + lifecycle { + ignore_changes = [settings_raw] + } +} + +resource "elasticstack_elasticsearch_index" "index2" { + name = var.index_name2 + deletion_protection = false + lifecycle { + ignore_changes = [settings_raw] + } +} + +resource "elasticstack_elasticsearch_index" "index3" { + name = var.index_name3 + deletion_protection = false + lifecycle { + ignore_changes = [settings_raw] + } +} + +resource "elasticstack_elasticsearch_index_alias" "test_alias" { + name = var.alias_name + + write_index = { + name = elasticstack_elasticsearch_index.index3.name + } + + read_indices = [ + { + name = elasticstack_elasticsearch_index.index1.name + }, + { + name = elasticstack_elasticsearch_index.index2.name + } + ] +} +` + +const testAccResourceAliasWriteIndexRemoveFirstDirect = ` +variable "alias_name" { + description = "The alias name" + type = string +} + +variable "index_name1" { + description = "The first index name" + type = string +} + +variable "index_name2" { + description = "The second index name" + type = string +} + +variable "index_name3" { + description = "The third index name" + type = string +} + +provider "elasticstack" { + elasticsearch {} +} + +resource "elasticstack_elasticsearch_index" "index1" { + name = var.index_name1 + deletion_protection = false + lifecycle { + ignore_changes = [settings_raw] + } +} + +resource "elasticstack_elasticsearch_index" "index2" { + name = var.index_name2 + deletion_protection = false + lifecycle { + ignore_changes = [settings_raw] + } +} + +resource "elasticstack_elasticsearch_index" "index3" { + name = var.index_name3 + deletion_protection = false + lifecycle { + ignore_changes = [settings_raw] + } +} + +resource "elasticstack_elasticsearch_index_alias" "test_alias" { + name = var.alias_name + + write_index = { + name = elasticstack_elasticsearch_index.index3.name + } + + read_indices = [{ + name = elasticstack_elasticsearch_index.index2.name + }] +} +` diff --git a/internal/elasticsearch/index/alias/create.go b/internal/elasticsearch/index/alias/create.go new file mode 100644 index 000000000..b9f79db6c --- /dev/null +++ b/internal/elasticsearch/index/alias/create.go @@ -0,0 +1,68 @@ +package alias + +import ( + "context" + + "github.com/elastic/terraform-provider-elasticstack/internal/clients/elasticsearch" + "github.com/elastic/terraform-provider-elasticstack/internal/diagutil" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +func (r *aliasResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var planModel tfModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &planModel)...) + if resp.Diagnostics.HasError() { + return + } + + aliasName := planModel.Name.ValueString() + + // Set the ID using client.ID + id, sdkDiags := r.client.ID(ctx, aliasName) + if sdkDiags.HasError() { + resp.Diagnostics.Append(diagutil.FrameworkDiagsFromSDK(sdkDiags)...) + return + } + planModel.ID = basetypes.NewStringValue(id.String()) + + // Get alias configurations from the plan + configs, diags := planModel.toAliasConfigs(ctx) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Convert to alias actions + var actions []elasticsearch.AliasAction + for _, config := range configs { + action := elasticsearch.AliasAction{ + Type: "add", + Index: config.Name, + Alias: aliasName, + IsWriteIndex: config.IsWriteIndex, + Filter: config.Filter, + IndexRouting: config.IndexRouting, + IsHidden: config.IsHidden, + Routing: config.Routing, + SearchRouting: config.SearchRouting, + } + actions = append(actions, action) + } + + // Create the alias atomically + resp.Diagnostics.Append(elasticsearch.UpdateAliasesAtomic(ctx, r.client, actions)...) + if resp.Diagnostics.HasError() { + return + } + + // Read back the alias to ensure state consistency, updating the current model + diags = readAliasIntoModel(ctx, r.client, aliasName, &planModel) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, planModel)...) +} diff --git a/internal/elasticsearch/index/alias/delete.go b/internal/elasticsearch/index/alias/delete.go new file mode 100644 index 000000000..645d815a6 --- /dev/null +++ b/internal/elasticsearch/index/alias/delete.go @@ -0,0 +1,41 @@ +package alias + +import ( + "context" + + "github.com/elastic/terraform-provider-elasticstack/internal/clients/elasticsearch" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +func (r *aliasResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var stateModel tfModel + + resp.Diagnostics.Append(req.State.Get(ctx, &stateModel)...) + if resp.Diagnostics.HasError() { + return + } + + aliasName := stateModel.Name.ValueString() + + // Get current configuration from state + currentConfigs, diags := stateModel.toAliasConfigs(ctx) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Build remove actions for all indices + var actions []elasticsearch.AliasAction + for _, config := range currentConfigs { + actions = append(actions, elasticsearch.AliasAction{ + Type: "remove", + Index: config.Name, + Alias: aliasName, + }) + } + + // Remove the alias from all indices + if len(actions) > 0 { + resp.Diagnostics.Append(elasticsearch.UpdateAliasesAtomic(ctx, r.client, actions)...) + } +} diff --git a/internal/elasticsearch/index/alias/models.go b/internal/elasticsearch/index/alias/models.go new file mode 100644 index 000000000..8e0750a9f --- /dev/null +++ b/internal/elasticsearch/index/alias/models.go @@ -0,0 +1,175 @@ +package alias + +import ( + "context" + "encoding/json" + + "github.com/elastic/terraform-provider-elasticstack/internal/models" + "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +type tfModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + WriteIndex types.Object `tfsdk:"write_index"` + ReadIndices types.Set `tfsdk:"read_indices"` +} + +type indexModel struct { + Name types.String `tfsdk:"name"` + Filter jsontypes.Normalized `tfsdk:"filter"` + IndexRouting types.String `tfsdk:"index_routing"` + IsHidden types.Bool `tfsdk:"is_hidden"` + Routing types.String `tfsdk:"routing"` + SearchRouting types.String `tfsdk:"search_routing"` +} + +// AliasIndexConfig represents a single index configuration within an alias +type AliasIndexConfig struct { + Name string + IsWriteIndex bool + Filter map[string]interface{} + IndexRouting string + IsHidden bool + Routing string + SearchRouting string +} + +func (model *tfModel) populateFromAPI(ctx context.Context, aliasName string, indices map[string]models.IndexAlias) diag.Diagnostics { + model.Name = types.StringValue(aliasName) + + var writeIndex *indexModel + var readIndices []indexModel + + for indexName, aliasData := range indices { + // Convert IndexAlias to indexModel + index, err := indexFromAlias(indexName, aliasData) + if err != nil { + return err + } + + if aliasData.IsWriteIndex { + writeIndex = &index + } else { + readIndices = append(readIndices, index) + } + } + + // Set write index + if writeIndex != nil { + writeIndexObj, diags := types.ObjectValueFrom(ctx, getIndexAttrTypes(), *writeIndex) + if diags.HasError() { + return diags + } + model.WriteIndex = writeIndexObj + } else { + model.WriteIndex = types.ObjectNull(getIndexAttrTypes()) + } + + // Set read indices + readIndicesSet, diags := types.SetValueFrom(ctx, types.ObjectType{ + AttrTypes: getIndexAttrTypes(), + }, readIndices) + if diags.HasError() { + return diags + } + model.ReadIndices = readIndicesSet + + return nil +} + +// indexFromAlias converts a models.IndexAlias to an indexModel +func indexFromAlias(indexName string, aliasData models.IndexAlias) (indexModel, diag.Diagnostics) { + index := indexModel{ + Name: types.StringValue(indexName), + IsHidden: types.BoolValue(aliasData.IsHidden), + } + + if aliasData.IndexRouting != "" { + index.IndexRouting = types.StringValue(aliasData.IndexRouting) + } + if aliasData.Routing != "" { + index.Routing = types.StringValue(aliasData.Routing) + } + if aliasData.SearchRouting != "" { + index.SearchRouting = types.StringValue(aliasData.SearchRouting) + } + if aliasData.Filter != nil { + filterBytes, err := json.Marshal(aliasData.Filter) + if err != nil { + return indexModel{}, diag.Diagnostics{ + diag.NewErrorDiagnostic("failed to marshal alias filter", err.Error()), + } + } + index.Filter = jsontypes.NewNormalizedValue(string(filterBytes)) + } + + return index, nil +} + +func (model *tfModel) toAliasConfigs(ctx context.Context) ([]AliasIndexConfig, diag.Diagnostics) { + var configs []AliasIndexConfig + + // Handle write index + if !model.WriteIndex.IsNull() { + var writeIndex indexModel + diags := model.WriteIndex.As(ctx, &writeIndex, basetypes.ObjectAsOptions{}) + if diags.HasError() { + return nil, diags + } + + config, configDiags := indexToConfig(writeIndex, true) + if configDiags.HasError() { + return nil, configDiags + } + configs = append(configs, config) + } + + // Handle read indices + if !model.ReadIndices.IsNull() { + var readIndices []indexModel + diags := model.ReadIndices.ElementsAs(ctx, &readIndices, false) + if diags.HasError() { + return nil, diags + } + + for _, readIndex := range readIndices { + config, configDiags := indexToConfig(readIndex, false) + if configDiags.HasError() { + return nil, configDiags + } + configs = append(configs, config) + } + } + + return configs, nil +} + +// indexToConfig converts an indexModel to AliasIndexConfig +func indexToConfig(index indexModel, isWriteIndex bool) (AliasIndexConfig, diag.Diagnostics) { + config := AliasIndexConfig{ + Name: index.Name.ValueString(), + IsWriteIndex: isWriteIndex, + IsHidden: index.IsHidden.ValueBool(), + } + + if !index.IndexRouting.IsNull() { + config.IndexRouting = index.IndexRouting.ValueString() + } + if !index.Routing.IsNull() { + config.Routing = index.Routing.ValueString() + } + if !index.SearchRouting.IsNull() { + config.SearchRouting = index.SearchRouting.ValueString() + } + if !index.Filter.IsNull() { + if diags := index.Filter.Unmarshal(&config.Filter); diags.HasError() { + return AliasIndexConfig{}, diags + } + } + + return config, nil +} diff --git a/internal/elasticsearch/index/alias/read.go b/internal/elasticsearch/index/alias/read.go new file mode 100644 index 000000000..fa48dbd2b --- /dev/null +++ b/internal/elasticsearch/index/alias/read.go @@ -0,0 +1,73 @@ +package alias + +import ( + "context" + + "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/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func (r *aliasResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var stateModel tfModel + + resp.Diagnostics.Append(req.State.Get(ctx, &stateModel)...) + if resp.Diagnostics.HasError() { + return + } + + aliasName := stateModel.Name.ValueString() + + // Read the alias and update the model + diags := readAliasIntoModel(ctx, r.client, aliasName, &stateModel) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Check if the alias was found + if stateModel.WriteIndex.IsNull() && stateModel.ReadIndices.IsNull() { + resp.State.RemoveResource(ctx) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, stateModel)...) +} + +// readAliasIntoModel reads an alias from Elasticsearch and populates the provided model +func readAliasIntoModel(ctx context.Context, client *clients.ApiClient, aliasName string, model *tfModel) diag.Diagnostics { + // Get the alias + indices, diags := elasticsearch.GetAlias(ctx, client, aliasName) + if diags.HasError() { + return diags + } + + // If no indices returned, the alias doesn't exist + if len(indices) == 0 { + // Set both to null to indicate the alias doesn't exist + model.WriteIndex = types.ObjectNull(getIndexAttrTypes()) + model.ReadIndices = types.SetNull(types.ObjectType{AttrTypes: getIndexAttrTypes()}) + return nil + } + + // Extract alias data from the response + aliasData := make(map[string]models.IndexAlias) + for indexName, index := range indices { + if alias, exists := index.Aliases[aliasName]; exists { + aliasData[indexName] = alias + } + } + + if len(aliasData) == 0 { + // Set both to null to indicate the alias doesn't exist + model.WriteIndex = types.ObjectNull(getIndexAttrTypes()) + model.ReadIndices = types.SetNull(types.ObjectType{AttrTypes: getIndexAttrTypes()}) + return nil + } + + // Update the model with API data + return model.populateFromAPI(ctx, aliasName, aliasData) +} diff --git a/internal/elasticsearch/index/alias/resource.go b/internal/elasticsearch/index/alias/resource.go new file mode 100644 index 000000000..28c7607d2 --- /dev/null +++ b/internal/elasticsearch/index/alias/resource.go @@ -0,0 +1,87 @@ +package alias + +import ( + "context" + "fmt" + + "github.com/elastic/terraform-provider-elasticstack/internal/clients" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +// Ensure provider defined types fully satisfy framework interfaces +var _ resource.Resource = &aliasResource{} +var _ resource.ResourceWithConfigure = &aliasResource{} +var _ resource.ResourceWithImportState = &aliasResource{} +var _ resource.ResourceWithValidateConfig = &aliasResource{} + +func NewAliasResource() resource.Resource { + return &aliasResource{} +} + +type aliasResource struct { + client *clients.ApiClient +} + +func (r *aliasResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_elasticsearch_index_alias" +} + +func (r *aliasResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + client, diags := clients.ConvertProviderData(req.ProviderData) + resp.Diagnostics.Append(diags...) + r.client = client +} + +func (r *aliasResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + // Retrieve import ID and save to id attribute + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} + +func (r *aliasResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) { + var config tfModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + // Validate that write_index doesn't appear in read_indices + if config.WriteIndex.IsNull() { + return + } + + if config.ReadIndices.IsNull() { + return + } + + // Get the write index name + var writeIndex indexModel + diags := config.WriteIndex.As(ctx, &writeIndex, basetypes.ObjectAsOptions{}) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + writeIndexName := writeIndex.Name.ValueString() + + // Only validate if write index name is not empty + if writeIndexName == "" { + return + } + + // Get all read indices + var readIndices []indexModel + if diags := config.ReadIndices.ElementsAs(ctx, &readIndices, false); !diags.HasError() { + for _, readIndex := range readIndices { + readIndexName := readIndex.Name.ValueString() + if readIndexName != "" && readIndexName == writeIndexName { + resp.Diagnostics.AddError( + "Invalid Configuration", + fmt.Sprintf("Index '%s' cannot be both a write index and a read index", writeIndexName), + ) + return + } + } + } +} diff --git a/internal/elasticsearch/index/alias/schema.go b/internal/elasticsearch/index/alias/schema.go new file mode 100644 index 000000000..d170f2ee4 --- /dev/null +++ b/internal/elasticsearch/index/alias/schema.go @@ -0,0 +1,113 @@ +package alias + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" + "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/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" +) + +func (r *aliasResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = getSchema() +} + +func getSchema() schema.Schema { + return schema.Schema{ + Description: "Manages an Elasticsearch alias. " + + "See the [alias documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-aliases.html) for more details.", + + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Generated ID of the alias resource.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "name": schema.StringAttribute{ + Description: "The alias name.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "write_index": schema.SingleNestedAttribute{ + Description: "The write index for the alias. Only one write index is allowed per alias.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: "Name of the write index.", + 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.", + Optional: true, + }, + "is_hidden": schema.BoolAttribute{ + Description: "If true, the alias is hidden.", + 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, + }, + "search_routing": schema.StringAttribute{ + Description: "Value used to route search operations to a specific shard.", + Optional: true, + }, + }, + }, + "read_indices": schema.SetNestedAttribute{ + Description: "Set of read indices for the alias.", + Optional: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: "Name of the read index.", + 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.", + Optional: true, + }, + "is_hidden": schema.BoolAttribute{ + Description: "If true, the alias is hidden.", + 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, + }, + "search_routing": schema.StringAttribute{ + Description: "Value used to route search operations to a specific shard.", + Optional: true, + }, + }, + }, + }, + }, + } +} + +func getIndexAttrTypes() map[string]attr.Type { + return getSchema().Attributes["write_index"].GetType().(attr.TypeWithAttributeTypes).AttributeTypes() +} diff --git a/internal/elasticsearch/index/alias/update.go b/internal/elasticsearch/index/alias/update.go new file mode 100644 index 000000000..332d523dd --- /dev/null +++ b/internal/elasticsearch/index/alias/update.go @@ -0,0 +1,97 @@ +package alias + +import ( + "context" + + "github.com/elastic/terraform-provider-elasticstack/internal/clients/elasticsearch" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +func (r *aliasResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + 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 + } + + aliasName := planModel.Name.ValueString() + + // Get current configuration from state + currentConfigs, diags := stateModel.toAliasConfigs(ctx) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Get planned configuration + plannedConfigs, diags := planModel.toAliasConfigs(ctx) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Build atomic actions + var actions []elasticsearch.AliasAction + + // Create maps for easy lookup + currentIndexMap := make(map[string]AliasIndexConfig) + for _, config := range currentConfigs { + currentIndexMap[config.Name] = config + } + + plannedIndexMap := make(map[string]AliasIndexConfig) + for _, config := range plannedConfigs { + plannedIndexMap[config.Name] = config + } + + // Remove indices that are no longer in the plan + for indexName := range currentIndexMap { + if _, exists := plannedIndexMap[indexName]; !exists { + actions = append(actions, elasticsearch.AliasAction{ + Type: "remove", + Index: indexName, + Alias: aliasName, + }) + } + } + + // Add or update indices in the plan + for _, config := range plannedConfigs { + action := elasticsearch.AliasAction{ + Type: "add", + Index: config.Name, + Alias: aliasName, + IsWriteIndex: config.IsWriteIndex, + Filter: config.Filter, + IndexRouting: config.IndexRouting, + IsHidden: config.IsHidden, + Routing: config.Routing, + SearchRouting: config.SearchRouting, + } + actions = append(actions, action) + } + + // Apply the atomic changes + if len(actions) > 0 { + resp.Diagnostics.Append(elasticsearch.UpdateAliasesAtomic(ctx, r.client, actions)...) + if resp.Diagnostics.HasError() { + return + } + } + + // Read back the alias to ensure state consistency, updating the current model + diags = readAliasIntoModel(ctx, r.client, aliasName, &planModel) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, planModel)...) +} diff --git a/internal/elasticsearch/index/index/acc_test.go b/internal/elasticsearch/index/index/acc_test.go index f0bfacff8..554c10782 100644 --- a/internal/elasticsearch/index/index/acc_test.go +++ b/internal/elasticsearch/index/index/acc_test.go @@ -270,16 +270,17 @@ provider "elasticstack" { resource "elasticstack_elasticsearch_index" "test" { name = "%s" - alias { - name = "test_alias_1" - } - - alias { - name = "test_alias_2" - filter = jsonencode({ - term = { "user.id" = "developer" } - }) - } + alias = [ + { + name = "test_alias_1" + }, + { + name = "test_alias_2" + filter = jsonencode({ + term = { "user.id" = "developer" } + }) + } + ] mappings = jsonencode({ properties = { @@ -303,9 +304,11 @@ provider "elasticstack" { resource "elasticstack_elasticsearch_index" "test" { name = "%s" - alias { - name = "test_alias_1" - } + alias = [ + { + name = "test_alias_1" + } + ] mappings = jsonencode({ properties = { @@ -328,9 +331,11 @@ resource "elasticstack_elasticsearch_index" "test" { name = "%s" number_of_replicas = 0 - alias { - name = "test_alias_1" - } + alias = [ + { + name = "test_alias_1" + } + ] mappings = jsonencode({ properties = { @@ -516,10 +521,12 @@ resource "elasticstack_elasticsearch_index_template" "test" { resource "elasticstack_elasticsearch_index" "test" { name = "%s" deletion_protection = false - alias { - name = "%s-alias" - is_write_index = true - } + alias = [ + { + name = "%s-alias" + is_write_index = true + } + ] lifecycle { ignore_changes = [mappings] } diff --git a/internal/elasticsearch/index/index/schema.go b/internal/elasticsearch/index/index/schema.go index 3c9264373..a0a8a4bb7 100644 --- a/internal/elasticsearch/index/index/schema.go +++ b/internal/elasticsearch/index/index/schema.go @@ -34,9 +34,66 @@ func getSchema() 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.", + "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"), + }, + }, + "alias": schema.SetNestedAttribute{ + Description: "Aliases for the index.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Set{ + setplanmodifier.UseStateForUnknown(), + }, + NestedObject: schema.NestedAttributeObject{ Attributes: map[string]schema.Attribute{ "name": schema.StringAttribute{ Description: "Index alias name.", @@ -95,58 +152,6 @@ func getSchema() schema.Schema { }, }, }, - "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.", @@ -511,7 +516,7 @@ func getSchema() schema.Schema { } func aliasElementType() attr.Type { - return getSchema().Blocks["alias"].Type().(attr.TypeWithElementType).ElementType() + return getSchema().Attributes["alias"].GetType().(attr.TypeWithElementType).ElementType() } func settingsElementType() attr.Type { diff --git a/internal/elasticsearch/index/index/update.go b/internal/elasticsearch/index/index/update.go index 69e8e9347..067f149b7 100644 --- a/internal/elasticsearch/index/index/update.go +++ b/internal/elasticsearch/index/index/update.go @@ -2,7 +2,7 @@ package index import ( "context" - "maps" + "reflect" "github.com/elastic/terraform-provider-elasticstack/internal/clients" "github.com/elastic/terraform-provider-elasticstack/internal/clients/elasticsearch" @@ -115,7 +115,7 @@ func (r *Resource) updateSettings(ctx context.Context, client *clients.ApiClient } } - if !maps.Equal(planDynamicSettings, stateDynamicSettings) { + if !deepEqual(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 { @@ -149,3 +149,24 @@ func (r *Resource) updateMappings(ctx context.Context, client *clients.ApiClient return nil } + +// deepEqual compares two maps for deep equality, handling slices and other types +// that are not comparable with maps.Equal +func deepEqual(a, b map[string]interface{}) bool { + if len(a) != len(b) { + return false + } + + for key, valueA := range a { + valueB, exists := b[key] + if !exists { + return false + } + + if !reflect.DeepEqual(valueA, valueB) { + return false + } + } + + return true +} diff --git a/internal/elasticsearch/transform/transform_test.go b/internal/elasticsearch/transform/transform_test.go index 45d457205..c3e4c6e5d 100644 --- a/internal/elasticsearch/transform/transform_test.go +++ b/internal/elasticsearch/transform/transform_test.go @@ -210,9 +210,9 @@ provider "elasticstack" { resource "elasticstack_elasticsearch_index" "test_source_index_1" { name = "source_index_for_transform" - alias { + alias = [{ name = "test_alias_1" - } + }] mappings = jsonencode({ properties = { @@ -229,9 +229,9 @@ resource "elasticstack_elasticsearch_index" "test_source_index_1" { resource "elasticstack_elasticsearch_index" "test_source_index_2" { name = "additional_index" - alias { + alias = [{ name = "test_alias_2" - } + }] mappings = jsonencode({ properties = { @@ -342,9 +342,9 @@ provider "elasticstack" { resource "elasticstack_elasticsearch_index" "test_index" { name = "%s" - alias { + alias = [{ name = "test_alias_1" - } + }] mappings = jsonencode({ properties = { diff --git a/provider/plugin_framework.go b/provider/plugin_framework.go index b5c681d33..e02d3e09d 100644 --- a/provider/plugin_framework.go +++ b/provider/plugin_framework.go @@ -8,6 +8,7 @@ import ( "github.com/elastic/terraform-provider-elasticstack/internal/clients/config" "github.com/elastic/terraform-provider-elasticstack/internal/elasticsearch/cluster/script" "github.com/elastic/terraform-provider-elasticstack/internal/elasticsearch/enrich" + "github.com/elastic/terraform-provider-elasticstack/internal/elasticsearch/index/alias" "github.com/elastic/terraform-provider-elasticstack/internal/elasticsearch/index/data_stream_lifecycle" "github.com/elastic/terraform-provider-elasticstack/internal/elasticsearch/index/index" "github.com/elastic/terraform-provider-elasticstack/internal/elasticsearch/index/indices" @@ -121,6 +122,7 @@ func (p *Provider) Resources(ctx context.Context) []func() resource.Resource { maintenance_window.NewResource, enrich.NewEnrichPolicyResource, role_mapping.NewRoleMappingResource, + alias.NewAliasResource, security_detection_rule.NewSecurityDetectionRuleResource, } }