diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b77bc0694..3e63590fa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -60,7 +60,9 @@ jobs: image: docker.elastic.co/elasticsearch/elasticsearch:${{ matrix.version }} env: discovery.type: single-node + xpack.license.self_generated.type: trial xpack.security.enabled: true + xpack.watcher.enabled: true repositories.url.allowed_urls: https://example.com/* path.repo: /tmp ELASTIC_PASSWORD: ${{ env.ELASTIC_PASSWORD }} diff --git a/CHANGELOG.md b/CHANGELOG.md index d1f9d7b9a..8aa80284d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,8 +16,9 @@ deletion_protection = false } ``` -- Add support for managing Kibana spaces ([#272](https://github.com/elastic/terraform-provider-elasticstack/pull/272)) -- Add support for managing Elasticsearch transforms ([#284](https://github.com/elastic/terraform-provider-elasticstack/pull/284)) +- Add `elasticstack_kibana_space` for managing Kibana spaces ([#272](https://github.com/elastic/terraform-provider-elasticstack/pull/272)) +- Add `elasticstack_elasticsearch_transform` for managing Elasticsearch transforms ([#284](https://github.com/elastic/terraform-provider-elasticstack/pull/284)) +- Add `elasticstack_elasticsearch_watch` for managing Elasticsearch Watches ([#155](https://github.com/elastic/terraform-provider-elasticstack/pull/155)) ### Fixed - Respect `ignore_unavailable` and `include_global_state` values when configuring SLM policies ([#224](https://github.com/elastic/terraform-provider-elasticstack/pull/224)) diff --git a/Makefile b/Makefile index 5de95ca75..668d6c7d1 100644 --- a/Makefile +++ b/Makefile @@ -86,6 +86,8 @@ docker-elasticsearch: docker-network ## Start Elasticsearch single node cluster -p 9200:9200 -p 9300:9300 \ -e "discovery.type=single-node" \ -e "xpack.security.enabled=true" \ + -e "xpack.watcher.enabled=true" \ + -e "xpack.license.self_generated.type=trial" \ -e "repositories.url.allowed_urls=https://example.com/*" \ -e "path.repo=/tmp" \ -e ELASTIC_PASSWORD=$(ELASTICSEARCH_PASSWORD) \ diff --git a/docs/resources/elasticsearch_logstash_pipeline.md b/docs/resources/elasticsearch_logstash_pipeline.md index eef769f49..7ee9b2f30 100644 --- a/docs/resources/elasticsearch_logstash_pipeline.md +++ b/docs/resources/elasticsearch_logstash_pipeline.md @@ -113,5 +113,5 @@ Optional: Import is supported using the following syntax: ```shell -terraform import elasticstack_elasticsearch_security_logstash_pipeline.my_pipeline / +terraform import elasticstack_elasticsearch_logstash_pipeline.my_pipeline / ``` diff --git a/docs/resources/elasticsearch_watch.md b/docs/resources/elasticsearch_watch.md new file mode 100644 index 000000000..3545fbd75 --- /dev/null +++ b/docs/resources/elasticsearch_watch.md @@ -0,0 +1,74 @@ +--- +subcategory: "Watcher" +layout: "" +page_title: "Elasticstack: elasticstack_elasticsearch_watch Resource" +description: |- + Adds and manages a Watch. +--- + +# Resource: elasticstack_elasticsearch_watch + +Adds and manages a Watch. See: https://www.elastic.co/guide/en/elasticsearch/reference/current/watcher-api.html + +## Example Usage + +```terraform +provider "elasticstack" { + elasticsearch {} +} + +resource "elasticstack_elasticsearch_watch" "example" { + watch_id = "test_watch" + active = true + + trigger = jsonencode({ + "schedule" = { + "cron" = "0 0/1 * * * ?" + } + }) + input = jsonencode({ + "none" = {} + }) + condition = jsonencode({ + "always" = {} + }) + actions = jsonencode({}) + metadata = jsonencode({ + "example_key" = "example_value" + }) + throttle_period_in_millis = 10000 +} + +output "watch" { + value = elasticstack_elasticsearch_watch.example.watch_id +} +``` + + +## Schema + +### Required + +- `trigger` (String) The trigger that defines when the watch should run. +- `watch_id` (String) Identifier for the watch. + +### Optional + +- `actions` (String) The list of actions that will be run if the condition matches. +- `active` (Boolean) Defines whether the watch is active or inactive by default. The default value is true, which means the watch is active by default. +- `condition` (String) The condition that defines if the actions should be run. +- `input` (String) The input that defines the input that loads the data for the watch. +- `metadata` (String) Metadata json that will be copied into the history entries. +- `throttle_period_in_millis` (Number) Minimum time in milliseconds between actions being run. Defaults to 5000. + +### Read-Only + +- `id` (String) Internal identifier of the resource. + +## Import + +Import is supported using the following syntax: + +```shell +terraform import elasticstack_elasticsearch_watch.watch_id / +``` diff --git a/examples/resources/elasticstack_elasticsearch_logstash_pipeline/import.sh b/examples/resources/elasticstack_elasticsearch_logstash_pipeline/import.sh index 64f978a7c..8fccf7097 100644 --- a/examples/resources/elasticstack_elasticsearch_logstash_pipeline/import.sh +++ b/examples/resources/elasticstack_elasticsearch_logstash_pipeline/import.sh @@ -1 +1 @@ -terraform import elasticstack_elasticsearch_security_logstash_pipeline.my_pipeline / +terraform import elasticstack_elasticsearch_logstash_pipeline.my_pipeline / diff --git a/examples/resources/elasticstack_elasticsearch_watch/import.sh b/examples/resources/elasticstack_elasticsearch_watch/import.sh new file mode 100644 index 000000000..b1cfa552a --- /dev/null +++ b/examples/resources/elasticstack_elasticsearch_watch/import.sh @@ -0,0 +1 @@ +terraform import elasticstack_elasticsearch_watch.watch_id / diff --git a/examples/resources/elasticstack_elasticsearch_watch/resource.tf b/examples/resources/elasticstack_elasticsearch_watch/resource.tf new file mode 100644 index 000000000..7e0150975 --- /dev/null +++ b/examples/resources/elasticstack_elasticsearch_watch/resource.tf @@ -0,0 +1,29 @@ +provider "elasticstack" { + elasticsearch {} +} + +resource "elasticstack_elasticsearch_watch" "example" { + watch_id = "test_watch" + active = true + + trigger = jsonencode({ + "schedule" = { + "cron" = "0 0/1 * * * ?" + } + }) + input = jsonencode({ + "none" = {} + }) + condition = jsonencode({ + "always" = {} + }) + actions = jsonencode({}) + metadata = jsonencode({ + "example_key" = "example_value" + }) + throttle_period_in_millis = 10000 +} + +output "watch" { + value = elasticstack_elasticsearch_watch.example.watch_id +} diff --git a/internal/clients/elasticsearch/watch.go b/internal/clients/elasticsearch/watch.go new file mode 100644 index 000000000..23b776a10 --- /dev/null +++ b/internal/clients/elasticsearch/watch.go @@ -0,0 +1,84 @@ +package elasticsearch + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + + "github.com/elastic/terraform-provider-elasticstack/internal/clients" + "github.com/elastic/terraform-provider-elasticstack/internal/models" + "github.com/elastic/terraform-provider-elasticstack/internal/utils" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" +) + +func PutWatch(ctx context.Context, apiClient *clients.ApiClient, watch *models.PutWatch) diag.Diagnostics { + var diags diag.Diagnostics + watchBodyBytes, err := json.Marshal(watch.Body) + if err != nil { + return diag.FromErr(err) + } + esClient, err := apiClient.GetESClient() + if err != nil { + return diag.FromErr(err) + } + body := esClient.Watcher.PutWatch.WithBody(bytes.NewReader(watchBodyBytes)) + active := esClient.Watcher.PutWatch.WithActive(watch.Active) + res, err := esClient.Watcher.PutWatch(watch.WatchID, active, body, esClient.Watcher.PutWatch.WithContext(ctx)) + if err != nil { + return diag.FromErr(err) + } + defer res.Body.Close() + if diags := utils.CheckError(res, "Unable to create or update watch"); diags.HasError() { + return diags + } + + return diags +} + +func GetWatch(ctx context.Context, apiClient *clients.ApiClient, watchID string) (*models.Watch, diag.Diagnostics) { + var diags diag.Diagnostics + + esClient, err := apiClient.GetESClient() + if err != nil { + return nil, diag.FromErr(err) + } + + res, err := esClient.Watcher.GetWatch(watchID, esClient.Watcher.GetWatch.WithContext(ctx)) + if err != nil { + return nil, diag.FromErr(err) + } + defer res.Body.Close() + if res.StatusCode == http.StatusNotFound { + return nil, nil + } + if diags := utils.CheckError(res, "Unable to find watch on cluster."); diags.HasError() { + return nil, diags + } + + var watch models.Watch + if err := json.NewDecoder(res.Body).Decode(&watch); err != nil { + return nil, diag.FromErr(err) + } + + watch.WatchID = watchID + return &watch, diags +} + +func DeleteWatch(ctx context.Context, apiClient *clients.ApiClient, watchID string) diag.Diagnostics { + var diags diag.Diagnostics + esClient, err := apiClient.GetESClient() + if err != nil { + return diag.FromErr(err) + } + res, err := esClient.Watcher.DeleteWatch(watchID, esClient.Watcher.DeleteWatch.WithContext(ctx)) + + if err != nil && res.IsError() { + return diag.FromErr(err) + } + defer res.Body.Close() + if diags := utils.CheckError(res, "Unable to delete watch"); diags.HasError() { + return diags + } + return diags +} diff --git a/internal/elasticsearch/watcher/watch.go b/internal/elasticsearch/watcher/watch.go new file mode 100644 index 000000000..c30f05bbe --- /dev/null +++ b/internal/elasticsearch/watcher/watch.go @@ -0,0 +1,244 @@ +package watcher + +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 ResourceWatch() *schema.Resource { + watchSchema := map[string]*schema.Schema{ + "id": { + Description: "Internal identifier of the resource.", + Type: schema.TypeString, + Computed: true, + }, + "watch_id": { + Description: "Identifier for the watch.", + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "active": { + Description: "Defines whether the watch is active or inactive by default. The default value is true, which means the watch is active by default.", + Type: schema.TypeBool, + Optional: true, + Default: true, + }, + "trigger": { + Description: "The trigger that defines when the watch should run.", + Type: schema.TypeString, + ValidateFunc: validation.StringIsJSON, + DiffSuppressFunc: utils.DiffJsonSuppress, + Required: true, + }, + "input": { + Description: "The input that defines the input that loads the data for the watch.", + Type: schema.TypeString, + ValidateFunc: validation.StringIsJSON, + DiffSuppressFunc: utils.DiffJsonSuppress, + Optional: true, + Default: "{\"none\":{}}", + }, + "condition": { + Description: "The condition that defines if the actions should be run.", + Type: schema.TypeString, + ValidateFunc: validation.StringIsJSON, + DiffSuppressFunc: utils.DiffJsonSuppress, + Optional: true, + Default: "{\"always\":{}}", + }, + "actions": { + Description: "The list of actions that will be run if the condition matches.", + Type: schema.TypeString, + ValidateFunc: validation.StringIsJSON, + DiffSuppressFunc: utils.DiffJsonSuppress, + Optional: true, + Default: "{}", + }, + "metadata": { + Description: "Metadata json that will be copied into the history entries.", + Type: schema.TypeString, + ValidateFunc: validation.StringIsJSON, + DiffSuppressFunc: utils.DiffJsonSuppress, + Optional: true, + Default: "{}", + }, + "throttle_period_in_millis": { + Description: "Minimum time in milliseconds between actions being run. Defaults to 5000.", + Type: schema.TypeInt, + Optional: true, + Default: 5000, + }, + } + + return &schema.Resource{ + Description: "Manage Watches. See, https://www.elastic.co/guide/en/elasticsearch/reference/current/watcher-api.html", + + CreateContext: resourceWatchPut, + UpdateContext: resourceWatchPut, + ReadContext: resourceWatchRead, + DeleteContext: resourceWatchDelete, + + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Schema: watchSchema, + } +} + +func resourceWatchPut(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client, diags := clients.NewApiClient(d, meta) + if diags.HasError() { + return diags + } + + watchID := d.Get("watch_id").(string) + id, diags := client.ID(ctx, watchID) + if diags.HasError() { + return diags + } + + var watch models.PutWatch + watch.WatchID = watchID + watch.Active = d.Get("active").(bool) + + var trigger map[string]interface{} + if err := json.Unmarshal([]byte(d.Get("trigger").(string)), &trigger); err != nil { + return diag.FromErr(err) + } + watch.Body.Trigger = trigger + + var input map[string]interface{} + if err := json.Unmarshal([]byte(d.Get("input").(string)), &input); err != nil { + return diag.FromErr(err) + } + watch.Body.Input = input + + var condition map[string]interface{} + if err := json.Unmarshal([]byte(d.Get("condition").(string)), &condition); err != nil { + return diag.FromErr(err) + } + watch.Body.Condition = condition + + var actions map[string]interface{} + if err := json.Unmarshal([]byte(d.Get("actions").(string)), &actions); err != nil { + return diag.FromErr(err) + } + watch.Body.Actions = actions + + var metadata map[string]interface{} + if err := json.Unmarshal([]byte(d.Get("metadata").(string)), &metadata); err != nil { + return diag.FromErr(err) + } + watch.Body.Metadata = metadata + + watch.Body.Throttle_period_in_millis = d.Get("throttle_period_in_millis").(int) + + if diags := elasticsearch.PutWatch(ctx, client, &watch); diags.HasError() { + return diags + } + + d.SetId(id.String()) + return resourceWatchRead(ctx, d, meta) +} + +func resourceWatchRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client, diags := clients.NewApiClient(d, meta) + if diags.HasError() { + return diags + } + resourceID, diags := clients.ResourceIDFromStr(d.Id()) + if diags.HasError() { + return diags + } + + watch, diags := elasticsearch.GetWatch(ctx, client, resourceID) + if watch == nil && diags == nil { + tflog.Warn(ctx, fmt.Sprintf(`Watch "%s" not found, removing from state`, resourceID)) + d.SetId("") + return diags + } + if diags.HasError() { + return diags + } + + if err := d.Set("watch_id", watch.WatchID); err != nil { + return diag.FromErr(err) + } + if err := d.Set("active", watch.Status.State.Active); err != nil { + return diag.FromErr(err) + } + + trigger, err := json.Marshal(watch.Body.Trigger) + if err != nil { + return diag.FromErr(err) + } + if err := d.Set("trigger", string(trigger)); err != nil { + return diag.FromErr(err) + } + + input, err := json.Marshal(watch.Body.Input) + if err != nil { + return diag.FromErr(err) + } + if err := d.Set("input", string(input)); err != nil { + return diag.FromErr(err) + } + + condition, err := json.Marshal(watch.Body.Condition) + if err != nil { + return diag.FromErr(err) + } + if err := d.Set("condition", string(condition)); err != nil { + return diag.FromErr(err) + } + + actions, err := json.Marshal(watch.Body.Actions) + if err != nil { + return diag.FromErr(err) + } + if err := d.Set("actions", string(actions)); err != nil { + return diag.FromErr(err) + } + + metadata, err := json.Marshal(watch.Body.Metadata) + if err != nil { + return diag.FromErr(err) + } + if err := d.Set("metadata", string(metadata)); err != nil { + return diag.FromErr(err) + } + + if err := d.Set("throttle_period_in_millis", watch.Body.Throttle_period_in_millis); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceWatchDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client, diags := clients.NewApiClient(d, meta) + if diags.HasError() { + return diags + } + resourceID, diags := clients.ResourceIDFromStr(d.Id()) + if diags.HasError() { + return diags + } + + if diags := elasticsearch.DeleteWatch(ctx, client, resourceID); diags.HasError() { + return diags + } + return nil +} diff --git a/internal/elasticsearch/watcher/watch_test.go b/internal/elasticsearch/watcher/watch_test.go new file mode 100644 index 000000000..1169ba9e3 --- /dev/null +++ b/internal/elasticsearch/watcher/watch_test.go @@ -0,0 +1,150 @@ +package watcher_test + +import ( + "fmt" + "net/http" + "testing" + + "github.com/elastic/terraform-provider-elasticstack/internal/acctest" + "github.com/elastic/terraform-provider-elasticstack/internal/clients" + sdkacctest "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +func TestResourceWatch(t *testing.T) { + watchID := sdkacctest.RandStringFromCharSet(10, sdkacctest.CharSetAlphaNum) + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + CheckDestroy: checkResourceWatchDestroy, + ProtoV5ProviderFactories: acctest.Providers, + Steps: []resource.TestStep{ + { + Config: testAccResourceWatchCreate(watchID), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_elasticsearch_watch.test", "watch_id", watchID), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_watch.test", "active", "false"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_watch.test", "trigger", `{"schedule":{"cron":"0 0/1 * * * ?"}}`), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_watch.test", "input", `{"none":{}}`), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_watch.test", "condition", `{"always":{}}`), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_watch.test", "actions", `{}`), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_watch.test", "metadata", `{}`), + ), + }, + { + Config: testAccResourceWatchUpdate(watchID), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_elasticsearch_watch.test", "watch_id", watchID), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_watch.test", "active", "true"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_watch.test", "trigger", `{"schedule":{"cron":"0 0/2 * * * ?"}}`), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_watch.test", "input", `{"simple":{"name":"example"}}`), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_watch.test", "condition", `{"never":{}}`), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_watch.test", "actions", `{"log":{"logging":{"level":"info","text":"example logging text"}}}`), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_watch.test", "metadata", `{"example_key":"example_value"}`), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_watch.test", "throttle_period_in_millis", "10000"), + ), + }, + }, + }) +} + +func testAccResourceWatchCreate(watchID string) string { + return fmt.Sprintf(` + provider "elasticstack" { + elasticsearch {} + } + + resource "elasticstack_elasticsearch_watch" "test" { + watch_id = "%s" + active = false + + trigger = <