diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c78e4042..32b53502b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Migrate `elasticstack_kibana_action_connector` to the Terraform plugin framework ([#1269](https://github.com/elastic/terraform-provider-elasticstack/pull/1269)) - Migrate `elasticstack_elasticsearch_security_role_mapping` resource and data source to Terraform Plugin Framework ([#1279](https://github.com/elastic/terraform-provider-elasticstack/pull/1279)) - Add support for `inactivity_timeout` in `elasticstack_fleet_agent_policy` ([#641](https://github.com/elastic/terraform-provider-elasticstack/issues/641)) +- Migrate `elasticstack_elasticsearch_script` resource to Terraform Plugin Framework ([#1297](https://github.com/elastic/terraform-provider-elasticstack/pull/1297)) - Add support for `kafka` output types in `elasticstack_fleet_output` ([#1302](https://github.com/elastic/terraform-provider-elasticstack/pull/1302)) - Add support for `prevent_initial_backfill` to `elasticstack_kibana_slo` ([#1071](https://github.com/elastic/terraform-provider-elasticstack/pull/1071)) - [Refactor] Regenerate the SLO client using the current OpenAPI spec ([#1303](https://github.com/elastic/terraform-provider-elasticstack/pull/1303)) diff --git a/docs/resources/elasticsearch_script.md b/docs/resources/elasticsearch_script.md index d3996234b..9d2920b17 100644 --- a/docs/resources/elasticsearch_script.md +++ b/docs/resources/elasticsearch_script.md @@ -55,12 +55,12 @@ resource "elasticstack_elasticsearch_script" "my_search_template" { ### Optional - `context` (String) Context in which the script or search template should run. -- `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)) - `params` (String) Parameters for the script or search template. ### Read-Only -- `id` (String) The ID of this resource. +- `id` (String) Internal identifier of the resource ### Nested Schema for `elasticsearch_connection` diff --git a/docs/resources/elasticsearch_watch.md b/docs/resources/elasticsearch_watch.md index 3625f84d3..4464ea28d 100644 --- a/docs/resources/elasticsearch_watch.md +++ b/docs/resources/elasticsearch_watch.md @@ -4,12 +4,12 @@ page_title: "elasticstack_elasticsearch_watch Resource - terraform-provider-elasticstack" subcategory: "Elasticsearch" description: |- - Manage Watches. See, https://www.elastic.co/guide/en/elasticsearch/reference/current/watcher-api.html + Manage Watches. See the Watcher API documentation https://www.elastic.co/guide/en/elasticsearch/reference/current/watcher-api.html for more details. --- # elasticstack_elasticsearch_watch (Resource) -Manage Watches. See, https://www.elastic.co/guide/en/elasticsearch/reference/current/watcher-api.html +Manage Watches. See the [Watcher API documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/watcher-api.html) for more details. ## Example Usage diff --git a/internal/clients/elasticsearch/cluster.go b/internal/clients/elasticsearch/cluster.go index 3e3ddf7ba..9c935545b 100644 --- a/internal/clients/elasticsearch/cluster.go +++ b/internal/clients/elasticsearch/cluster.go @@ -10,6 +10,7 @@ import ( "github.com/elastic/terraform-provider-elasticstack/internal/clients" "github.com/elastic/terraform-provider-elasticstack/internal/diagutil" "github.com/elastic/terraform-provider-elasticstack/internal/models" + fwdiag "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" ) @@ -234,33 +235,33 @@ func GetSettings(ctx context.Context, apiClient *clients.ApiClient) (map[string] return clusterSettings, diags } -func GetScript(ctx context.Context, apiClient *clients.ApiClient, id string) (*models.Script, diag.Diagnostics) { +func GetScript(ctx context.Context, apiClient *clients.ApiClient, id string) (*models.Script, fwdiag.Diagnostics) { esClient, err := apiClient.GetESClient() if err != nil { - return nil, diag.FromErr(err) + return nil, fwdiag.Diagnostics{fwdiag.NewErrorDiagnostic("Failed to get ES client", err.Error())} } res, err := esClient.GetScript(id, esClient.GetScript.WithContext(ctx)) if err != nil { - return nil, diag.FromErr(err) + return nil, fwdiag.Diagnostics{fwdiag.NewErrorDiagnostic("Failed to get script", err.Error())} } defer res.Body.Close() if res.StatusCode == http.StatusNotFound { return nil, nil } - if diags := diagutil.CheckError(res, fmt.Sprintf("Unable to get stored script: %s", id)); diags.HasError() { + if diags := diagutil.CheckErrorFromFW(res, fmt.Sprintf("Unable to get stored script: %s", id)); diags.HasError() { return nil, diags } var scriptResponse struct { Script *models.Script `json:"script"` } if err := json.NewDecoder(res.Body).Decode(&scriptResponse); err != nil { - return nil, diag.FromErr(err) + return nil, fwdiag.Diagnostics{fwdiag.NewErrorDiagnostic("Failed to decode script response", err.Error())} } return scriptResponse.Script, nil } -func PutScript(ctx context.Context, apiClient *clients.ApiClient, script *models.Script) diag.Diagnostics { +func PutScript(ctx context.Context, apiClient *clients.ApiClient, script *models.Script) fwdiag.Diagnostics { req := struct { Script *models.Script `json:"script"` }{ @@ -268,34 +269,34 @@ func PutScript(ctx context.Context, apiClient *clients.ApiClient, script *models } scriptBytes, err := json.Marshal(req) if err != nil { - return diag.FromErr(err) + return fwdiag.Diagnostics{fwdiag.NewErrorDiagnostic("Failed to marshal script", err.Error())} } esClient, err := apiClient.GetESClient() if err != nil { - return diag.FromErr(err) + return fwdiag.Diagnostics{fwdiag.NewErrorDiagnostic("Failed to get ES client", err.Error())} } res, err := esClient.PutScript(script.ID, bytes.NewReader(scriptBytes), esClient.PutScript.WithContext(ctx), esClient.PutScript.WithScriptContext(script.Context)) if err != nil { - return diag.FromErr(err) + return fwdiag.Diagnostics{fwdiag.NewErrorDiagnostic("Failed to put script", err.Error())} } defer res.Body.Close() - if diags := diagutil.CheckError(res, "Unable to put stored script"); diags.HasError() { + if diags := diagutil.CheckErrorFromFW(res, "Unable to put stored script"); diags.HasError() { return diags } return nil } -func DeleteScript(ctx context.Context, apiClient *clients.ApiClient, id string) diag.Diagnostics { +func DeleteScript(ctx context.Context, apiClient *clients.ApiClient, id string) fwdiag.Diagnostics { esClient, err := apiClient.GetESClient() if err != nil { - return diag.FromErr(err) + return fwdiag.Diagnostics{fwdiag.NewErrorDiagnostic("Failed to get ES client", err.Error())} } res, err := esClient.DeleteScript(id, esClient.DeleteScript.WithContext(ctx)) if err != nil { - return diag.FromErr(err) + return fwdiag.Diagnostics{fwdiag.NewErrorDiagnostic("Failed to delete script", err.Error())} } defer res.Body.Close() - if diags := diagutil.CheckError(res, fmt.Sprintf("Unable to delete script: %s", id)); diags.HasError() { + if diags := diagutil.CheckErrorFromFW(res, fmt.Sprintf("Unable to delete script: %s", id)); diags.HasError() { return diags } return nil diff --git a/internal/elasticsearch/cluster/script.go b/internal/elasticsearch/cluster/script.go deleted file mode 100644 index 3bb1387e8..000000000 --- a/internal/elasticsearch/cluster/script.go +++ /dev/null @@ -1,151 +0,0 @@ -package cluster - -import ( - "context" - "encoding/json" - "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/models" - "github.com/elastic/terraform-provider-elasticstack/internal/utils" - "github.com/hashicorp/terraform-plugin-log/tflog" - "github.com/hashicorp/terraform-plugin-sdk/v2/diag" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" -) - -func ResourceScript() *schema.Resource { - scriptSchema := map[string]*schema.Schema{ - "script_id": { - Description: "Identifier for the stored script. Must be unique within the cluster.", - Type: schema.TypeString, - Required: true, - ForceNew: true, - }, - "lang": { - Description: "Script language. For search templates, use `mustache`.", - Type: schema.TypeString, - Required: true, - ValidateFunc: validation.StringInSlice([]string{"painless", "expression", "mustache", "java"}, false), - }, - "source": { - Description: "For scripts, a string containing the script. For search templates, an object containing the search template.", - Type: schema.TypeString, - Required: true, - }, - "params": { - Description: "Parameters for the script or search template.", - Type: schema.TypeString, - Optional: true, - DiffSuppressFunc: utils.DiffJsonSuppress, - ValidateFunc: validation.StringIsJSON, - }, - "context": { - Description: "Context in which the script or search template should run.", - Type: schema.TypeString, - Optional: true, - }, - } - utils.AddConnectionSchema(scriptSchema) - - return &schema.Resource{ - Description: "Creates or updates a stored script or search template. See the [create stored script API documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/create-stored-script-api.html) for more details.", - - CreateContext: resourceScriptPut, - UpdateContext: resourceScriptPut, - ReadContext: resourceScriptRead, - DeleteContext: resourceScriptDelete, - - Importer: &schema.ResourceImporter{ - StateContext: schema.ImportStatePassthroughContext, - }, - - Schema: scriptSchema, - } -} - -func resourceScriptRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client, diags := clients.NewApiClientFromSDKResource(d, meta) - if diags.HasError() { - return diags - } - - id := d.Id() - compId, diags := clients.CompositeIdFromStr(id) - if diags.HasError() { - return diags - } - - script, diags := elasticsearch.GetScript(ctx, client, compId.ResourceId) - if script == nil && diags == nil { - tflog.Warn(ctx, fmt.Sprintf(`Script "%s" not found, removing from state`, compId.ResourceId)) - d.SetId("") - return nil - } - if diags.HasError() { - return diags - } - - if err := d.Set("script_id", compId.ResourceId); err != nil { - return diag.FromErr(err) - } - if err := d.Set("lang", script.Language); err != nil { - return diag.FromErr(err) - } - if err := d.Set("source", script.Source); err != nil { - return diag.FromErr(err) - } - - return diags -} - -func resourceScriptPut(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client, diags := clients.NewApiClientFromSDKResource(d, meta) - if diags.HasError() { - return diags - } - - scriptID := d.Get("script_id").(string) - id, diags := client.ID(ctx, scriptID) - if diags.HasError() { - return diags - } - - script := models.Script{ - ID: scriptID, - Language: d.Get("lang").(string), - Source: d.Get("source").(string), - } - if paramsJSON, ok := d.GetOk("params"); ok { - var params map[string]interface{} - bytes := []byte(paramsJSON.(string)) - err := json.Unmarshal(bytes, ¶ms) - if err != nil { - return diag.FromErr(err) - } - script.Params = params - } - if scriptContext, ok := d.GetOk("context"); ok { - script.Context = scriptContext.(string) - } - if diags := elasticsearch.PutScript(ctx, client, &script); diags.HasError() { - return diags - } - - d.SetId(id.String()) - return resourceScriptRead(ctx, d, meta) -} - -func resourceScriptDelete(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 - } - return elasticsearch.DeleteScript(ctx, client, compId.ResourceId) -} diff --git a/internal/elasticsearch/cluster/script/acc_test.go b/internal/elasticsearch/cluster/script/acc_test.go new file mode 100644 index 000000000..ecd8abdb2 --- /dev/null +++ b/internal/elasticsearch/cluster/script/acc_test.go @@ -0,0 +1,220 @@ +package script_test + +import ( + "fmt" + "testing" + + "github.com/elastic/terraform-provider-elasticstack/internal/acctest" + "github.com/elastic/terraform-provider-elasticstack/internal/clients" + sdkacctest "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/stretchr/testify/require" +) + +func TestAccResourceScript(t *testing.T) { + scriptID := sdkacctest.RandStringFromCharSet(10, sdkacctest.CharSetAlphaNum) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + CheckDestroy: checkScriptDestroy, + ProtoV6ProviderFactories: acctest.Providers, + Steps: []resource.TestStep{ + { + Config: testAccScriptCreate(scriptID), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_elasticsearch_script.test", "script_id", scriptID), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_script.test", "lang", "painless"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_script.test", "source", "Math.log(_score * 2) + params['my_modifier']"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_script.test", "context", "score"), + ), + }, + { + Config: testAccScriptUpdate(scriptID), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_elasticsearch_script.test", "script_id", scriptID), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_script.test", "lang", "painless"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_script.test", "source", "Math.log(_score * 4) + params['changed_modifier']"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_script.test", "params", `{"changed_modifier":2}`), + ), + }, + { + // Ensure the provider doesn't panic if the script has been deleted outside of the Terraform flow + PreConfig: func() { + client, err := clients.NewAcceptanceTestingClient() + require.NoError(t, err) + + esClient, err := client.GetESClient() + require.NoError(t, err) + + _, err = esClient.DeleteScript(scriptID) + require.NoError(t, err) + }, + Config: testAccScriptUpdate(scriptID), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_elasticsearch_script.test", "script_id", scriptID), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_script.test", "lang", "painless"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_script.test", "source", "Math.log(_score * 4) + params['changed_modifier']"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_script.test", "params", `{"changed_modifier":2}`), + ), + }, + }, + }) +} + +func TestAccResourceScriptSearchTemplate(t *testing.T) { + scriptID := sdkacctest.RandStringFromCharSet(10, sdkacctest.CharSetAlphaNum) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + CheckDestroy: checkScriptDestroy, + ProtoV6ProviderFactories: acctest.Providers, + Steps: []resource.TestStep{ + { + Config: testAccSearchTemplateCreate(scriptID), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_elasticsearch_script.search_template_test", "script_id", scriptID), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_script.search_template_test", "lang", "mustache"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_script.search_template_test", "source", `{"from":"{{from}}","query":{"match":{"message":"{{query_string}}"}},"size":"{{size}}"}`), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_script.search_template_test", "params", `{"query_string":"My query string"}`), + ), + }, + }, + }) +} + +func TestAccResourceScriptFromSDK(t *testing.T) { + scriptID := sdkacctest.RandStringFromCharSet(10, sdkacctest.CharSetAlphaNum) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + Steps: []resource.TestStep{ + { + // Create the script with the last provider version where the script resource was built on the SDK + ExternalProviders: map[string]resource.ExternalProvider{ + "elasticstack": { + Source: "elastic/elasticstack", + VersionConstraint: "0.11.17", + }, + }, + Config: testAccScriptCreateFromSDK(scriptID), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_elasticsearch_script.test", "script_id", scriptID), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_script.test", "lang", "painless"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_script.test", "source", "Math.log(_score * 2) + params['my_modifier']"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_script.test", "context", "score"), + ), + }, + { + ProtoV6ProviderFactories: acctest.Providers, + Config: testAccScriptCreateFromSDK(scriptID), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_elasticsearch_script.test", "script_id", scriptID), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_script.test", "lang", "painless"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_script.test", "source", "Math.log(_score * 2) + params['my_modifier']"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_script.test", "context", "score"), + ), + }, + }, + }) +} + +func testAccScriptCreate(id string) string { + return fmt.Sprintf(` +provider "elasticstack" { + elasticsearch {} +} + +resource "elasticstack_elasticsearch_script" "test" { + script_id = "%s" + lang = "painless" + source = "Math.log(_score * 2) + params['my_modifier']" + context = "score" +} + `, id) +} + +func testAccScriptUpdate(id string) string { + return fmt.Sprintf(` +provider "elasticstack" { + elasticsearch {} +} + +resource "elasticstack_elasticsearch_script" "test" { + script_id = "%s" + lang = "painless" + source = "Math.log(_score * 4) + params['changed_modifier']" + params = jsonencode({ + changed_modifier = 2 + }) +} + `, id) +} + +func testAccSearchTemplateCreate(id string) string { + return fmt.Sprintf(` +provider "elasticstack" { + elasticsearch {} +} + +resource "elasticstack_elasticsearch_script" "search_template_test" { + script_id = "%s" + lang = "mustache" + source = jsonencode({ + query = { + match = { + message = "{{query_string}}" + } + } + from = "{{from}}" + size = "{{size}}" + }) + params = jsonencode({ + query_string = "My query string" + }) +} + `, id) +} + +func testAccScriptCreateFromSDK(id string) string { + return fmt.Sprintf(` +provider "elasticstack" { + elasticsearch {} +} + +resource "elasticstack_elasticsearch_script" "test" { + script_id = "%s" + lang = "painless" + source = "Math.log(_score * 2) + params['my_modifier']" + context = "score" +} + `, id) +} + +func checkScriptDestroy(s *terraform.State) error { + client, err := clients.NewAcceptanceTestingClient() + if err != nil { + return err + } + + for _, rs := range s.RootModule().Resources { + if rs.Type != "elasticstack_elasticsearch_script" { + continue + } + + compId, _ := clients.CompositeIdFromStr(rs.Primary.ID) + esClient, err := client.GetESClient() + if err != nil { + return err + } + res, err := esClient.GetScript(compId.ResourceId) + if err != nil { + return err + } + + if res.StatusCode != 404 { + return fmt.Errorf("script (%s) still exists", compId.ResourceId) + } + } + return nil +} diff --git a/internal/elasticsearch/cluster/script/create.go b/internal/elasticsearch/cluster/script/create.go new file mode 100644 index 000000000..f71a42a67 --- /dev/null +++ b/internal/elasticsearch/cluster/script/create.go @@ -0,0 +1,12 @@ +package script + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +func (r *scriptResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + diags := r.update(ctx, req.Plan, &resp.State) + resp.Diagnostics.Append(diags...) +} diff --git a/internal/elasticsearch/cluster/script/delete.go b/internal/elasticsearch/cluster/script/delete.go new file mode 100644 index 000000000..5af8c4078 --- /dev/null +++ b/internal/elasticsearch/cluster/script/delete.go @@ -0,0 +1,31 @@ +package script + +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/resource" +) + +func (r *scriptResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data ScriptData + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + compId, diags := clients.CompositeIdFromStrFw(data.Id.ValueString()) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + client, diags := clients.MaybeNewApiClientFromFrameworkResource(ctx, data.ElasticsearchConnection, r.client) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(elasticsearch.DeleteScript(ctx, client, compId.ResourceId)...) +} diff --git a/internal/elasticsearch/cluster/script/models.go b/internal/elasticsearch/cluster/script/models.go new file mode 100644 index 000000000..c5e0a26d4 --- /dev/null +++ b/internal/elasticsearch/cluster/script/models.go @@ -0,0 +1,16 @@ +package script + +import ( + "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type ScriptData struct { + Id types.String `tfsdk:"id"` + ElasticsearchConnection types.List `tfsdk:"elasticsearch_connection"` + ScriptId types.String `tfsdk:"script_id"` + Lang types.String `tfsdk:"lang"` + Source types.String `tfsdk:"source"` + Params jsontypes.Normalized `tfsdk:"params"` + Context types.String `tfsdk:"context"` +} diff --git a/internal/elasticsearch/cluster/script/read.go b/internal/elasticsearch/cluster/script/read.go new file mode 100644 index 000000000..90b56a1b9 --- /dev/null +++ b/internal/elasticsearch/cluster/script/read.go @@ -0,0 +1,105 @@ +package script + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/elastic/terraform-provider-elasticstack/internal/clients" + "github.com/elastic/terraform-provider-elasticstack/internal/clients/elasticsearch" + "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" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +func (r *scriptResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data ScriptData + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + compId, diags := clients.CompositeIdFromStrFw(data.Id.ValueString()) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + scriptId := compId.ResourceId + + client, diags := clients.MaybeNewApiClientFromFrameworkResource(ctx, data.ElasticsearchConnection, r.client) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Use the helper read function + readData, readDiags := r.read(ctx, scriptId, client) + resp.Diagnostics.Append(readDiags...) + if resp.Diagnostics.HasError() { + return + } + + // Check if script was found + if readData.ScriptId.IsNull() { + tflog.Warn(ctx, fmt.Sprintf(`Script "%s" not found, removing from state`, compId.ResourceId)) + resp.State.RemoveResource(ctx) + return + } + + // Preserve connection and ID from original state + readData.ElasticsearchConnection = data.ElasticsearchConnection + readData.Id = data.Id + + // Preserve context from state as it's not returned by the API + readData.Context = data.Context + + // Preserve params from state if API didn't return them + if readData.Params.IsNull() && !data.Params.IsNull() { + readData.Params = data.Params + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &readData)...) +} + +func (r *scriptResource) read(ctx context.Context, scriptID string, client *clients.ApiClient) (ScriptData, diag.Diagnostics) { + var data ScriptData + var diags diag.Diagnostics + + script, frameworkDiags := elasticsearch.GetScript(ctx, client, scriptID) + diags.Append(frameworkDiags...) + if diags.HasError() { + return data, diags + } + + if script == nil { + // Script not found - return empty data with null ScriptId to signal not found + data.ScriptId = types.StringNull() + return data, diags + } + + data.ScriptId = types.StringValue(scriptID) + data.Lang = types.StringValue(script.Language) + data.Source = types.StringValue(script.Source) + + // Handle params if returned by the API + if len(script.Params) > 0 { + paramsBytes, err := json.Marshal(script.Params) + if err != nil { + diags.AddError("Error marshaling script params", err.Error()) + return data, diags + } + data.Params = jsontypes.NewNormalizedValue(string(paramsBytes)) + } else { + data.Params = jsontypes.NewNormalizedNull() + } + // Note: If params were set but API doesn't return them, they are preserved from state + // This maintains backwards compatibility + + // Note: context is not returned by the Elasticsearch API (json:"-" in model) + // It's only used during script creation, so we preserve it from state + // This is consistent with the SDKv2 implementation + + return data, diags +} diff --git a/internal/elasticsearch/cluster/script/resource.go b/internal/elasticsearch/cluster/script/resource.go new file mode 100644 index 000000000..34130fe98 --- /dev/null +++ b/internal/elasticsearch/cluster/script/resource.go @@ -0,0 +1,26 @@ +package script + +import ( + "context" + + "github.com/elastic/terraform-provider-elasticstack/internal/clients" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +func NewScriptResource() resource.Resource { + return &scriptResource{} +} + +type scriptResource struct { + client *clients.ApiClient +} + +func (r *scriptResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_elasticsearch_script" +} + +func (r *scriptResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + client, diags := clients.ConvertProviderData(req.ProviderData) + resp.Diagnostics.Append(diags...) + r.client = client +} diff --git a/internal/elasticsearch/cluster/script/schema.go b/internal/elasticsearch/cluster/script/schema.go new file mode 100644 index 000000000..b37672c2b --- /dev/null +++ b/internal/elasticsearch/cluster/script/schema.go @@ -0,0 +1,61 @@ +package script + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + + providerschema "github.com/elastic/terraform-provider-elasticstack/internal/schema" +) + +func (r *scriptResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = GetSchema() +} + +func GetSchema() schema.Schema { + return schema.Schema{ + MarkdownDescription: "Creates or updates a stored script or search template. See the [create stored script API documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/create-stored-script-api.html) for more details.", + Blocks: map[string]schema.Block{ + "elasticsearch_connection": providerschema.GetEsFWConnectionBlock("elasticsearch_connection", false), + }, + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + MarkdownDescription: "Internal identifier of the resource", + Computed: true, + }, + "script_id": schema.StringAttribute{ + MarkdownDescription: "Identifier for the stored script. Must be unique within the cluster.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "lang": schema.StringAttribute{ + MarkdownDescription: "Script language. For search templates, use `mustache`.", + Required: true, + Validators: []validator.String{ + stringvalidator.OneOf("painless", "expression", "mustache", "java"), + }, + }, + "source": schema.StringAttribute{ + MarkdownDescription: "For scripts, a string containing the script. For search templates, an object containing the search template.", + Required: true, + }, + "params": schema.StringAttribute{ + MarkdownDescription: "Parameters for the script or search template.", + Optional: true, + CustomType: jsontypes.NormalizedType{}, + }, + "context": schema.StringAttribute{ + MarkdownDescription: "Context in which the script or search template should run.", + Optional: true, + }, + }, + } +} diff --git a/internal/elasticsearch/cluster/script/update.go b/internal/elasticsearch/cluster/script/update.go new file mode 100644 index 000000000..639cef5c7 --- /dev/null +++ b/internal/elasticsearch/cluster/script/update.go @@ -0,0 +1,93 @@ +package script + +import ( + "context" + "encoding/json" + + "github.com/elastic/terraform-provider-elasticstack/internal/clients" + "github.com/elastic/terraform-provider-elasticstack/internal/clients/elasticsearch" + "github.com/elastic/terraform-provider-elasticstack/internal/diagutil" + "github.com/elastic/terraform-provider-elasticstack/internal/models" + "github.com/elastic/terraform-provider-elasticstack/internal/utils" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func (r *scriptResource) update(ctx context.Context, plan tfsdk.Plan, state *tfsdk.State) diag.Diagnostics { + var data ScriptData + var diags diag.Diagnostics + diags.Append(plan.Get(ctx, &data)...) + if diags.HasError() { + return diags + } + + scriptId := data.ScriptId.ValueString() + id, sdkDiags := r.client.ID(ctx, scriptId) + diags.Append(diagutil.FrameworkDiagsFromSDK(sdkDiags)...) + if diags.HasError() { + return diags + } + + client, diags := clients.MaybeNewApiClientFromFrameworkResource(ctx, data.ElasticsearchConnection, r.client) + diags.Append(diags...) + if diags.HasError() { + return diags + } + + script := models.Script{ + ID: scriptId, + Language: data.Lang.ValueString(), + Source: data.Source.ValueString(), + } + + if utils.IsKnown(data.Params) { + paramsStr := data.Params.ValueString() + if paramsStr != "" { + var params map[string]interface{} + err := json.Unmarshal([]byte(paramsStr), ¶ms) + if err != nil { + diags.AddError("Error unmarshaling script params", err.Error()) + return diags + } + script.Params = params + } + } + + if utils.IsKnown(data.Context) { + script.Context = data.Context.ValueString() + } + + diags.Append(elasticsearch.PutScript(ctx, client, &script)...) + if diags.HasError() { + return diags + } + + // Read the script back from Elasticsearch to populate state + readData, readDiags := r.read(ctx, scriptId, client) + diags.Append(readDiags...) + if diags.HasError() { + return diags + } + + // Preserve connection and ID from the original data + readData.ElasticsearchConnection = data.ElasticsearchConnection + readData.Id = types.StringValue(id.String()) + + // Preserve context from the original data as it's not returned by the API + readData.Context = data.Context + + // Preserve params from original data if API didn't return them + if readData.Params.IsNull() && !data.Params.IsNull() { + readData.Params = data.Params + } + + diags.Append(state.Set(ctx, &readData)...) + return diags +} + +func (r *scriptResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + diags := r.update(ctx, req.Plan, &resp.State) + resp.Diagnostics.Append(diags...) +} diff --git a/internal/elasticsearch/security/role_mapping_data_source_test.go b/internal/elasticsearch/security/role_mapping_data_source_test.go deleted file mode 100644 index 026257a15..000000000 --- a/internal/elasticsearch/security/role_mapping_data_source_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package security_test - -import ( - "testing" - - "github.com/elastic/terraform-provider-elasticstack/internal/acctest" - "github.com/elastic/terraform-provider-elasticstack/internal/acctest/checks" - "github.com/hashicorp/terraform-plugin-testing/helper/resource" -) - -func TestAccDataSourceSecurityRoleMapping(t *testing.T) { - resource.Test(t, resource.TestCase{ - PreCheck: func() { acctest.PreCheck(t) }, - ProtoV6ProviderFactories: acctest.Providers, - Steps: []resource.TestStep{ - { - Config: testAccDataSourceSecurityRoleMapping, - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("data.elasticstack_elasticsearch_security_role_mapping.test", "name", "data_source_test"), - resource.TestCheckResourceAttr("data.elasticstack_elasticsearch_security_role_mapping.test", "enabled", "true"), - checks.TestCheckResourceListAttr("data.elasticstack_elasticsearch_security_role_mapping.test", "roles", []string{"admin"}), - resource.TestCheckResourceAttr("data.elasticstack_elasticsearch_security_role_mapping.test", "rules", `{"any":[{"field":{"username":"esadmin"}},{"field":{"groups":"cn=admins,dc=example,dc=com"}}]}`), - resource.TestCheckResourceAttr("data.elasticstack_elasticsearch_security_role_mapping.test", "metadata", `{"version":1}`), - ), - }, - }, - }) -} - -const testAccDataSourceSecurityRoleMapping = ` -provider "elasticstack" { - elasticsearch {} -} - -resource "elasticstack_elasticsearch_security_role_mapping" "test" { - name = "data_source_test" - enabled = true - roles = [ - "admin" - ] - rules = jsonencode({ - any = [ - { field = { username = "esadmin" } }, - { field = { groups = "cn=admins,dc=example,dc=com" } }, - ] - }) - - metadata = jsonencode({ version = 1 }) -} - -data "elasticstack_elasticsearch_security_role_mapping" "test" { - name = elasticstack_elasticsearch_security_role_mapping.test.name -} -` diff --git a/internal/elasticsearch/watcher/watch.go b/internal/elasticsearch/watcher/watch.go index 7190e4db8..2f1629689 100644 --- a/internal/elasticsearch/watcher/watch.go +++ b/internal/elasticsearch/watcher/watch.go @@ -89,7 +89,7 @@ func ResourceWatch() *schema.Resource { } return &schema.Resource{ - Description: "Manage Watches. See, https://www.elastic.co/guide/en/elasticsearch/reference/current/watcher-api.html", + Description: "Manage Watches. See the [Watcher API documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/watcher-api.html) for more details.", CreateContext: resourceWatchPut, UpdateContext: resourceWatchPut, diff --git a/provider/plugin_framework.go b/provider/plugin_framework.go index bba736241..8487b6bd8 100644 --- a/provider/plugin_framework.go +++ b/provider/plugin_framework.go @@ -6,6 +6,7 @@ import ( "github.com/elastic/terraform-provider-elasticstack/internal/apm/agent_configuration" "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/cluster/script" "github.com/elastic/terraform-provider-elasticstack/internal/elasticsearch/enrich" "github.com/elastic/terraform-provider-elasticstack/internal/elasticsearch/index/data_stream_lifecycle" "github.com/elastic/terraform-provider-elasticstack/internal/elasticsearch/index/index" @@ -113,6 +114,7 @@ func (p *Provider) Resources(ctx context.Context) []func() resource.Resource { output.NewResource, server_host.NewResource, system_user.NewSystemUserResource, + script.NewScriptResource, maintenance_window.NewResource, enrich.NewEnrichPolicyResource, role_mapping.NewRoleMappingResource, diff --git a/provider/provider.go b/provider/provider.go index 3e78eee50..d0a177b56 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -92,7 +92,6 @@ func New(version string) *schema.Provider { "elasticstack_elasticsearch_security_user": security.ResourceUser(), "elasticstack_elasticsearch_snapshot_lifecycle": cluster.ResourceSlm(), "elasticstack_elasticsearch_snapshot_repository": cluster.ResourceSnapshotRepository(), - "elasticstack_elasticsearch_script": cluster.ResourceScript(), "elasticstack_elasticsearch_transform": transform.ResourceTransform(), "elasticstack_elasticsearch_watch": watcher.ResourceWatch(),