diff --git a/CHANGELOG.md b/CHANGELOG.md index be8bf6a24..d4a8ca9aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,10 @@ inputs = { - Add `advanced_monitoring_options` to `elasticstack_fleet_agent_policy` to configure HTTP monitoring endpoint and diagnostics settings ([#1537](https://github.com/elastic/terraform-provider-elasticstack/pull/1537)) - Move the `input` block to an `inputs` map in `elasticstack_fleet_integration_policy` ([#1482](https://github.com/elastic/terraform-provider-elasticstack/pull/1482)) - Fix `elasticstack_elasticsearch_ml_anomaly_detection_job` import to be resilient to sparse state values +- Fix a state consistency issue when an `elasticstack_elasticsearch_ml_datafeed_state` resource without `start` configured is started after being stopped. ([#1563](https://github.com/elastic/terraform-provider-elasticstack/pull/1563)) +- Fix a state consistency issue when `elasticstack_elasticsearch_ml_datafeed_state` `start` and `end` times are specified in a timezone that is not the server timezone `elasticstack_elasticsearch_ml_datafeed_state` resource without `start` configured is started after being stopped. ([#1563](https://github.com/elastic/terraform-provider-elasticstack/pull/1563)) +- Fix an issue where `elasticstack_elasticsearch_ml_datafeed_state` `start` and `end` times where treated by the provider as unix seconds, but by the API as unix milliseconds. + ## [0.13.1] - 2025-12-12 diff --git a/internal/elasticsearch/ml/datafeed_state/acc_test.go b/internal/elasticsearch/ml/datafeed_state/acc_test.go index b75ab5ab9..a309ac015 100644 --- a/internal/elasticsearch/ml/datafeed_state/acc_test.go +++ b/internal/elasticsearch/ml/datafeed_state/acc_test.go @@ -119,3 +119,81 @@ func TestAccResourceMLDatafeedState_withTimes(t *testing.T) { }, }) } + +func TestAccResourceMLDatafeedState_multiStep(t *testing.T) { + jobID := fmt.Sprintf("test-job-%s", sdkacctest.RandStringFromCharSet(10, sdkacctest.CharSetAlphaNum)) + datafeedID := fmt.Sprintf("test-datafeed-%s", sdkacctest.RandStringFromCharSet(10, sdkacctest.CharSetAlphaNum)) + indexName := fmt.Sprintf("test-datafeed-index-%s", sdkacctest.RandStringFromCharSet(10, sdkacctest.CharSetAlphaNum)) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + Steps: []resource.TestStep{ + { + ProtoV6ProviderFactories: acctest.Providers, + ConfigDirectory: acctest.NamedTestCaseDirectory("closed_stopped"), + ConfigVariables: config.Variables{ + "job_id": config.StringVariable(jobID), + "datafeed_id": config.StringVariable(datafeedID), + "index_name": config.StringVariable(indexName), + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_elasticsearch_ml_datafeed_state.nginx", "state", "stopped"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_ml_datafeed_state.nginx", "force", "true"), + ), + }, + { + ProtoV6ProviderFactories: acctest.Providers, + ConfigDirectory: acctest.NamedTestCaseDirectory("job_opened"), + ConfigVariables: config.Variables{ + "job_id": config.StringVariable(jobID), + "datafeed_id": config.StringVariable(datafeedID), + "index_name": config.StringVariable(indexName), + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_elasticsearch_ml_datafeed_state.nginx", "state", "stopped"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_ml_datafeed_state.nginx", "force", "false"), + ), + }, + { + ProtoV6ProviderFactories: acctest.Providers, + ConfigDirectory: acctest.NamedTestCaseDirectory("started_no_time"), + ConfigVariables: config.Variables{ + "job_id": config.StringVariable(jobID), + "datafeed_id": config.StringVariable(datafeedID), + "index_name": config.StringVariable(indexName), + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_elasticsearch_ml_datafeed_state.nginx", "state", "started"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_ml_datafeed_state.nginx", "force", "false"), + ), + }, + { + ProtoV6ProviderFactories: acctest.Providers, + ConfigDirectory: acctest.NamedTestCaseDirectory("stopped_job_open"), + ConfigVariables: config.Variables{ + "job_id": config.StringVariable(jobID), + "datafeed_id": config.StringVariable(datafeedID), + "index_name": config.StringVariable(indexName), + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_elasticsearch_ml_datafeed_state.nginx", "state", "stopped"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_ml_datafeed_state.nginx", "force", "false"), + ), + }, + { + ProtoV6ProviderFactories: acctest.Providers, + ConfigDirectory: acctest.NamedTestCaseDirectory("started_with_time"), + ConfigVariables: config.Variables{ + "job_id": config.StringVariable(jobID), + "datafeed_id": config.StringVariable(datafeedID), + "index_name": config.StringVariable(indexName), + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_elasticsearch_ml_datafeed_state.nginx", "state", "started"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_ml_datafeed_state.nginx", "force", "false"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_ml_datafeed_state.nginx", "start", "2025-12-01T00:00:00+01:00"), + ), + }, + }, + }) +} diff --git a/internal/elasticsearch/ml/datafeed_state/models.go b/internal/elasticsearch/ml/datafeed_state/models.go index 4a670e915..98a0289ce 100644 --- a/internal/elasticsearch/ml/datafeed_state/models.go +++ b/internal/elasticsearch/ml/datafeed_state/models.go @@ -1,9 +1,9 @@ package datafeed_state import ( - "strconv" "time" + "github.com/elastic/terraform-provider-elasticstack/internal/elasticsearch/ml/datafeed" "github.com/elastic/terraform-provider-elasticstack/internal/models" "github.com/elastic/terraform-provider-elasticstack/internal/utils" "github.com/elastic/terraform-provider-elasticstack/internal/utils/customtypes" @@ -25,42 +25,57 @@ type MLDatafeedStateData struct { Timeouts timeouts.Value `tfsdk:"timeouts"` } -func (d *MLDatafeedStateData) GetStartAsString() (string, diag.Diagnostics) { - return d.getTimeAttributeAsString(d.Start) -} - -func (d *MLDatafeedStateData) GetEndAsString() (string, diag.Diagnostics) { - return d.getTimeAttributeAsString(d.End) -} - -func (d *MLDatafeedStateData) getTimeAttributeAsString(val timetypes.RFC3339) (string, diag.Diagnostics) { - if !utils.IsKnown(val) { - return "", nil +func timeInSameLocation(ms int64, source timetypes.RFC3339) (time.Time, diag.Diagnostics) { + t := time.UnixMilli(ms) + if !utils.IsKnown(source) { + return t, nil } - valTime, diags := val.ValueRFC3339Time() + sourceTime, diags := source.ValueRFC3339Time() if diags.HasError() { - return "", diags + return t, diags } - return strconv.FormatInt(valTime.Unix(), 10), nil + + t = t.In(sourceTime.Location()) + return t, nil } func (d *MLDatafeedStateData) SetStartAndEndFromAPI(datafeedStats *models.DatafeedStats) diag.Diagnostics { var diags diag.Diagnostics - if datafeedStats.RunningState == nil { - diags.AddWarning("Running state was empty for a started datafeed", "The Elasticsearch API returned an empty running state for a Datafeed which was successfully started. Ignoring start and end response values.") - return diags + + if datafeed.State(datafeedStats.State) == datafeed.StateStarted { + if datafeedStats.RunningState == nil { + diags.AddWarning("Running state was empty for a started datafeed", "The Elasticsearch API returned an empty running state for a Datafeed which was successfully started. Ignoring start and end response values.") + return diags + } + + if datafeedStats.RunningState.SearchInterval != nil { + start, timeDiags := timeInSameLocation(datafeedStats.RunningState.SearchInterval.StartMS, d.Start) + diags.Append(timeDiags...) + if diags.HasError() { + return diags + } + + end, timeDiags := timeInSameLocation(datafeedStats.RunningState.SearchInterval.EndMS, d.End) + diags.Append(timeDiags...) + if diags.HasError() { + return diags + } + + d.Start = timetypes.NewRFC3339TimeValue(start) + d.End = timetypes.NewRFC3339TimeValue(end) + } + + if datafeedStats.RunningState.RealTimeConfigured { + d.End = timetypes.NewRFC3339Null() + } } - if datafeedStats.RunningState.SearchInterval != nil { - d.Start = timetypes.NewRFC3339TimeValue(time.UnixMilli(datafeedStats.RunningState.SearchInterval.StartMS)) - d.End = timetypes.NewRFC3339TimeValue(time.UnixMilli(datafeedStats.RunningState.SearchInterval.EndMS)) - } else { + if d.Start.IsUnknown() { d.Start = timetypes.NewRFC3339Null() - d.End = timetypes.NewRFC3339Null() } - if datafeedStats.RunningState.RealTimeConfigured { + if d.End.IsUnknown() { d.End = timetypes.NewRFC3339Null() } diff --git a/internal/elasticsearch/ml/datafeed_state/read.go b/internal/elasticsearch/ml/datafeed_state/read.go index 05f29eeac..0bfe05a92 100644 --- a/internal/elasticsearch/ml/datafeed_state/read.go +++ b/internal/elasticsearch/ml/datafeed_state/read.go @@ -6,7 +6,6 @@ import ( "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/elasticsearch/ml/datafeed" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/types" @@ -64,9 +63,7 @@ func (r *mlDatafeedStateResource) read(ctx context.Context, data MLDatafeedState data.Id = types.StringValue(compId.String()) - if datafeed.State(datafeedStats.State) == datafeed.StateStarted { - diags.Append(data.SetStartAndEndFromAPI(datafeedStats)...) - } + diags.Append(data.SetStartAndEndFromAPI(datafeedStats)...) return &data, diags } diff --git a/internal/elasticsearch/ml/datafeed_state/schema.go b/internal/elasticsearch/ml/datafeed_state/schema.go index f38f15f69..6ad951901 100644 --- a/internal/elasticsearch/ml/datafeed_state/schema.go +++ b/internal/elasticsearch/ml/datafeed_state/schema.go @@ -74,6 +74,7 @@ func GetSchema() schema.Schema { Computed: true, PlanModifiers: []planmodifier.String{ stringplanmodifier.UseStateForUnknown(), + SetUnknownIfStateHasChanges(), }, }, "end": schema.StringAttribute{ diff --git a/internal/elasticsearch/ml/datafeed_state/set_unknown_if_state_has_changes.go b/internal/elasticsearch/ml/datafeed_state/set_unknown_if_state_has_changes.go new file mode 100644 index 000000000..73d1c5d27 --- /dev/null +++ b/internal/elasticsearch/ml/datafeed_state/set_unknown_if_state_has_changes.go @@ -0,0 +1,51 @@ +package datafeed_state + +import ( + "context" + + "github.com/elastic/terraform-provider-elasticstack/internal/utils" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// SetUnknownIfStateHasChanges returns a plan modifier that sets the current attribute to unknown +// if the state attribute has changed between state and config. +func SetUnknownIfStateHasChanges() planmodifier.String { + return setUnknownIfStateHasChanges{} +} + +type setUnknownIfStateHasChanges struct{} + +func (s setUnknownIfStateHasChanges) Description(ctx context.Context) string { + return "Sets the attribute value to unknown if the state attribute has changed" +} + +func (s setUnknownIfStateHasChanges) MarkdownDescription(ctx context.Context) string { + return s.Description(ctx) +} + +func (s setUnknownIfStateHasChanges) PlanModifyString(ctx context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) { + // Only apply this modifier if we have both state and config + if req.State.Raw.IsNull() || req.Config.Raw.IsNull() { + return + } + + // Continue using the config value if it's explicitly set + if utils.IsKnown(req.ConfigValue) { + return + } + + // Get the state attribute from state and config to check if it has changed + var stateValue, configValue types.String + resp.Diagnostics.Append(req.State.GetAttribute(ctx, path.Root("state"), &stateValue)...) + resp.Diagnostics.Append(req.Config.GetAttribute(ctx, path.Root("state"), &configValue)...) + if resp.Diagnostics.HasError() { + return + } + + // If the state attribute has changed between state and config, set the current attribute to Unknown + if !stateValue.Equal(configValue) { + resp.PlanValue = types.StringUnknown() + } +} diff --git a/internal/elasticsearch/ml/datafeed_state/testdata/TestAccResourceMLDatafeedState_multiStep/closed_stopped/main.tf b/internal/elasticsearch/ml/datafeed_state/testdata/TestAccResourceMLDatafeedState_multiStep/closed_stopped/main.tf new file mode 100644 index 000000000..6d17f30f1 --- /dev/null +++ b/internal/elasticsearch/ml/datafeed_state/testdata/TestAccResourceMLDatafeedState_multiStep/closed_stopped/main.tf @@ -0,0 +1,121 @@ +variable "job_id" { + description = "The ML job ID" + type = string +} + +variable "datafeed_id" { + description = "The ML datafeed ID" + type = string +} + +variable "index_name" { + description = "The index name" + type = string +} + +provider "elasticstack" { + elasticsearch {} +} + +resource "elasticstack_elasticsearch_index" "test" { + name = var.index_name + deletion_protection = false + mappings = jsonencode({ + properties = { + "@timestamp" = { + type = "date" + } + nginx = { + properties = { + access = { + properties = { + body_sent = { + properties = { + bytes = { + type = "long" + } + } + } + geoip = { + properties = { + city_name = { + type = "keyword" + } + } + } + user_agent = { + properties = { + build = { + type = "keyword" + } + } + } + } + } + } + } + } + }) +} + +resource "elasticstack_elasticsearch_ml_anomaly_detection_job" "nginx" { + job_id = var.job_id + description = "Anomaly detection for network traffic" + analysis_config = { + bucket_span = "15m" + detectors = [ + { + function = "count" + detector_description = "count" + }, + { + function = "mean" + field_name = "nginx.access.body_sent.bytes" + detector_description = "mean(\"nginx.access.body_sent.bytes\")" + } + ] + influencers = ["nginx.access.geoip.city_name", "nginx.access.user_agent.build"] + model_prune_window = "30d" + } + analysis_limits = { + model_memory_limit = "10MB" + categorization_examples_limit = 4 + } + data_description = { + time_field = "@timestamp" + time_format = "epoch_ms" + } + model_snapshot_retention_days = 10 + daily_model_snapshot_retention_after_days = 1 +} + +resource "elasticstack_elasticsearch_ml_datafeed" "datafeed_nginx" { + datafeed_id = var.datafeed_id + job_id = elasticstack_elasticsearch_ml_anomaly_detection_job.nginx.job_id + query = jsonencode({ + bool = { + must = [ + { + match_all = {} + } + ] + } + }) + indices = [elasticstack_elasticsearch_index.test.name] +} + +resource "elasticstack_elasticsearch_ml_job_state" "nginx" { + job_id = elasticstack_elasticsearch_ml_anomaly_detection_job.nginx.job_id + state = "closed" +} + +resource "elasticstack_elasticsearch_ml_datafeed_state" "nginx" { + datafeed_id = elasticstack_elasticsearch_ml_datafeed.datafeed_nginx.datafeed_id + state = "stopped" + force = true + + depends_on = [ + elasticstack_elasticsearch_ml_datafeed.datafeed_nginx, + elasticstack_elasticsearch_ml_job_state.nginx + ] +} \ No newline at end of file diff --git a/internal/elasticsearch/ml/datafeed_state/testdata/TestAccResourceMLDatafeedState_multiStep/job_opened/main.tf b/internal/elasticsearch/ml/datafeed_state/testdata/TestAccResourceMLDatafeedState_multiStep/job_opened/main.tf new file mode 100644 index 000000000..b24077e3a --- /dev/null +++ b/internal/elasticsearch/ml/datafeed_state/testdata/TestAccResourceMLDatafeedState_multiStep/job_opened/main.tf @@ -0,0 +1,120 @@ +variable "job_id" { + description = "The ML job ID" + type = string +} + +variable "datafeed_id" { + description = "The ML datafeed ID" + type = string +} + +variable "index_name" { + description = "The index name" + type = string +} + +provider "elasticstack" { + elasticsearch {} +} + +resource "elasticstack_elasticsearch_index" "test" { + name = var.index_name + deletion_protection = false + mappings = jsonencode({ + properties = { + "@timestamp" = { + type = "date" + } + nginx = { + properties = { + access = { + properties = { + body_sent = { + properties = { + bytes = { + type = "long" + } + } + } + geoip = { + properties = { + city_name = { + type = "keyword" + } + } + } + user_agent = { + properties = { + build = { + type = "keyword" + } + } + } + } + } + } + } + } + }) +} + +resource "elasticstack_elasticsearch_ml_anomaly_detection_job" "nginx" { + job_id = var.job_id + description = "Anomaly detection for network traffic" + analysis_config = { + bucket_span = "15m" + detectors = [ + { + function = "count" + detector_description = "count" + }, + { + function = "mean" + field_name = "nginx.access.body_sent.bytes" + detector_description = "mean(\"nginx.access.body_sent.bytes\")" + } + ] + influencers = ["nginx.access.geoip.city_name", "nginx.access.user_agent.build"] + model_prune_window = "30d" + } + analysis_limits = { + model_memory_limit = "10MB" + categorization_examples_limit = 4 + } + data_description = { + time_field = "@timestamp" + time_format = "epoch_ms" + } + model_snapshot_retention_days = 10 + daily_model_snapshot_retention_after_days = 1 +} + +resource "elasticstack_elasticsearch_ml_datafeed" "datafeed_nginx" { + datafeed_id = var.datafeed_id + job_id = elasticstack_elasticsearch_ml_anomaly_detection_job.nginx.job_id + query = jsonencode({ + bool = { + must = [ + { + match_all = {} + } + ] + } + }) + indices = [elasticstack_elasticsearch_index.test.name] +} + +resource "elasticstack_elasticsearch_ml_job_state" "nginx" { + job_id = elasticstack_elasticsearch_ml_anomaly_detection_job.nginx.job_id + state = "opened" +} + +resource "elasticstack_elasticsearch_ml_datafeed_state" "nginx" { + datafeed_id = elasticstack_elasticsearch_ml_datafeed.datafeed_nginx.datafeed_id + state = "stopped" + + depends_on = [ + elasticstack_elasticsearch_ml_datafeed.datafeed_nginx, + elasticstack_elasticsearch_ml_job_state.nginx + ] +} diff --git a/internal/elasticsearch/ml/datafeed_state/testdata/TestAccResourceMLDatafeedState_multiStep/started_no_time/main.tf b/internal/elasticsearch/ml/datafeed_state/testdata/TestAccResourceMLDatafeedState_multiStep/started_no_time/main.tf new file mode 100644 index 000000000..008bac590 --- /dev/null +++ b/internal/elasticsearch/ml/datafeed_state/testdata/TestAccResourceMLDatafeedState_multiStep/started_no_time/main.tf @@ -0,0 +1,120 @@ +variable "job_id" { + description = "The ML job ID" + type = string +} + +variable "datafeed_id" { + description = "The ML datafeed ID" + type = string +} + +variable "index_name" { + description = "The index name" + type = string +} + +provider "elasticstack" { + elasticsearch {} +} + +resource "elasticstack_elasticsearch_index" "test" { + name = var.index_name + deletion_protection = false + mappings = jsonencode({ + properties = { + "@timestamp" = { + type = "date" + } + nginx = { + properties = { + access = { + properties = { + body_sent = { + properties = { + bytes = { + type = "long" + } + } + } + geoip = { + properties = { + city_name = { + type = "keyword" + } + } + } + user_agent = { + properties = { + build = { + type = "keyword" + } + } + } + } + } + } + } + } + }) +} + +resource "elasticstack_elasticsearch_ml_anomaly_detection_job" "nginx" { + job_id = var.job_id + description = "Anomaly detection for network traffic" + analysis_config = { + bucket_span = "15m" + detectors = [ + { + function = "count" + detector_description = "count" + }, + { + function = "mean" + field_name = "nginx.access.body_sent.bytes" + detector_description = "mean(\"nginx.access.body_sent.bytes\")" + } + ] + influencers = ["nginx.access.geoip.city_name", "nginx.access.user_agent.build"] + model_prune_window = "30d" + } + analysis_limits = { + model_memory_limit = "10MB" + categorization_examples_limit = 4 + } + data_description = { + time_field = "@timestamp" + time_format = "epoch_ms" + } + model_snapshot_retention_days = 10 + daily_model_snapshot_retention_after_days = 1 +} + +resource "elasticstack_elasticsearch_ml_datafeed" "datafeed_nginx" { + datafeed_id = var.datafeed_id + job_id = elasticstack_elasticsearch_ml_anomaly_detection_job.nginx.job_id + query = jsonencode({ + bool = { + must = [ + { + match_all = {} + } + ] + } + }) + indices = [elasticstack_elasticsearch_index.test.name] +} + +resource "elasticstack_elasticsearch_ml_job_state" "nginx" { + job_id = elasticstack_elasticsearch_ml_anomaly_detection_job.nginx.job_id + state = "opened" +} + +resource "elasticstack_elasticsearch_ml_datafeed_state" "nginx" { + datafeed_id = elasticstack_elasticsearch_ml_datafeed.datafeed_nginx.datafeed_id + state = "started" + + depends_on = [ + elasticstack_elasticsearch_ml_datafeed.datafeed_nginx, + elasticstack_elasticsearch_ml_job_state.nginx + ] +} diff --git a/internal/elasticsearch/ml/datafeed_state/testdata/TestAccResourceMLDatafeedState_multiStep/started_with_time/main.tf b/internal/elasticsearch/ml/datafeed_state/testdata/TestAccResourceMLDatafeedState_multiStep/started_with_time/main.tf new file mode 100644 index 000000000..8336cdcf8 --- /dev/null +++ b/internal/elasticsearch/ml/datafeed_state/testdata/TestAccResourceMLDatafeedState_multiStep/started_with_time/main.tf @@ -0,0 +1,125 @@ +variable "job_id" { + description = "The ML job ID" + type = string +} + +variable "datafeed_id" { + description = "The ML datafeed ID" + type = string +} + +variable "index_name" { + description = "The index name" + type = string +} + +provider "elasticstack" { + elasticsearch {} +} + +resource "elasticstack_elasticsearch_index" "test" { + name = var.index_name + deletion_protection = false + mappings = jsonencode({ + properties = { + "@timestamp" = { + type = "date" + } + nginx = { + properties = { + access = { + properties = { + body_sent = { + properties = { + bytes = { + type = "long" + } + } + } + geoip = { + properties = { + city_name = { + type = "keyword" + } + } + } + user_agent = { + properties = { + build = { + type = "keyword" + } + } + } + } + } + } + } + } + }) +} + +resource "elasticstack_elasticsearch_ml_anomaly_detection_job" "nginx" { + job_id = var.job_id + description = "Anomaly detection for network traffic" + analysis_config = { + bucket_span = "15m" + detectors = [ + { + function = "count" + detector_description = "count" + }, + { + function = "mean" + field_name = "nginx.access.body_sent.bytes" + detector_description = "mean(\"nginx.access.body_sent.bytes\")" + } + ] + influencers = ["nginx.access.geoip.city_name", "nginx.access.user_agent.build"] + model_prune_window = "30d" + } + analysis_limits = { + model_memory_limit = "10MB" + categorization_examples_limit = 4 + } + data_description = { + time_field = "@timestamp" + time_format = "epoch_ms" + } + model_snapshot_retention_days = 10 + daily_model_snapshot_retention_after_days = 1 +} + +resource "elasticstack_elasticsearch_ml_datafeed" "datafeed_nginx" { + datafeed_id = var.datafeed_id + job_id = elasticstack_elasticsearch_ml_anomaly_detection_job.nginx.job_id + query = jsonencode({ + bool = { + must = [ + { + match_all = {} + } + ] + } + }) + indices = [elasticstack_elasticsearch_index.test.name] +} + +resource "elasticstack_elasticsearch_ml_job_state" "nginx" { + job_id = elasticstack_elasticsearch_ml_anomaly_detection_job.nginx.job_id + state = "opened" +} + +resource "elasticstack_elasticsearch_ml_datafeed_state" "nginx" { + datafeed_id = elasticstack_elasticsearch_ml_datafeed.datafeed_nginx.datafeed_id + state = "started" + // Explicitly setting a non-standard timezone here. + // The RFC3339 type compares times including their timezone info. + // The resource must preserve the user supplied timezone info when reading from the API in order + // to avoid an inconsistent state error. + start = "2025-12-01T00:00:00+01:00" + + depends_on = [ + elasticstack_elasticsearch_ml_datafeed.datafeed_nginx, + elasticstack_elasticsearch_ml_job_state.nginx + ] +} diff --git a/internal/elasticsearch/ml/datafeed_state/testdata/TestAccResourceMLDatafeedState_multiStep/stopped_job_open/main.tf b/internal/elasticsearch/ml/datafeed_state/testdata/TestAccResourceMLDatafeedState_multiStep/stopped_job_open/main.tf new file mode 100644 index 000000000..b24077e3a --- /dev/null +++ b/internal/elasticsearch/ml/datafeed_state/testdata/TestAccResourceMLDatafeedState_multiStep/stopped_job_open/main.tf @@ -0,0 +1,120 @@ +variable "job_id" { + description = "The ML job ID" + type = string +} + +variable "datafeed_id" { + description = "The ML datafeed ID" + type = string +} + +variable "index_name" { + description = "The index name" + type = string +} + +provider "elasticstack" { + elasticsearch {} +} + +resource "elasticstack_elasticsearch_index" "test" { + name = var.index_name + deletion_protection = false + mappings = jsonencode({ + properties = { + "@timestamp" = { + type = "date" + } + nginx = { + properties = { + access = { + properties = { + body_sent = { + properties = { + bytes = { + type = "long" + } + } + } + geoip = { + properties = { + city_name = { + type = "keyword" + } + } + } + user_agent = { + properties = { + build = { + type = "keyword" + } + } + } + } + } + } + } + } + }) +} + +resource "elasticstack_elasticsearch_ml_anomaly_detection_job" "nginx" { + job_id = var.job_id + description = "Anomaly detection for network traffic" + analysis_config = { + bucket_span = "15m" + detectors = [ + { + function = "count" + detector_description = "count" + }, + { + function = "mean" + field_name = "nginx.access.body_sent.bytes" + detector_description = "mean(\"nginx.access.body_sent.bytes\")" + } + ] + influencers = ["nginx.access.geoip.city_name", "nginx.access.user_agent.build"] + model_prune_window = "30d" + } + analysis_limits = { + model_memory_limit = "10MB" + categorization_examples_limit = 4 + } + data_description = { + time_field = "@timestamp" + time_format = "epoch_ms" + } + model_snapshot_retention_days = 10 + daily_model_snapshot_retention_after_days = 1 +} + +resource "elasticstack_elasticsearch_ml_datafeed" "datafeed_nginx" { + datafeed_id = var.datafeed_id + job_id = elasticstack_elasticsearch_ml_anomaly_detection_job.nginx.job_id + query = jsonencode({ + bool = { + must = [ + { + match_all = {} + } + ] + } + }) + indices = [elasticstack_elasticsearch_index.test.name] +} + +resource "elasticstack_elasticsearch_ml_job_state" "nginx" { + job_id = elasticstack_elasticsearch_ml_anomaly_detection_job.nginx.job_id + state = "opened" +} + +resource "elasticstack_elasticsearch_ml_datafeed_state" "nginx" { + datafeed_id = elasticstack_elasticsearch_ml_datafeed.datafeed_nginx.datafeed_id + state = "stopped" + + depends_on = [ + elasticstack_elasticsearch_ml_datafeed.datafeed_nginx, + elasticstack_elasticsearch_ml_job_state.nginx + ] +} diff --git a/internal/elasticsearch/ml/datafeed_state/update.go b/internal/elasticsearch/ml/datafeed_state/update.go index f188d4cc1..e4e6b5cfc 100644 --- a/internal/elasticsearch/ml/datafeed_state/update.go +++ b/internal/elasticsearch/ml/datafeed_state/update.go @@ -181,18 +181,10 @@ func (r *mlDatafeedStateResource) performStateTransition(ctx context.Context, cl // Initiate the state change switch desiredState { case datafeed.StateStarted: - start, diags := data.GetStartAsString() - if diags.HasError() { - return false, diags - } - end, endDiags := data.GetEndAsString() - diags.Append(endDiags...) - if diags.HasError() { - return false, diags - } + start := data.Start.ValueString() + end := data.End.ValueString() - startDiags := elasticsearch.StartDatafeed(ctx, client, datafeedId, start, end, timeout) - diags.Append(startDiags...) + diags := elasticsearch.StartDatafeed(ctx, client, datafeedId, start, end, timeout) if diags.HasError() { return false, diags }