diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ffbadeef..06b65bd11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Allow version changes without a destroy/create cycle with `elasticstack_fleet_integration` ([#1255](https://github.com/elastic/terraform-provider-elasticstack/pull/1255)). This fixes an issue where it was impossible to upgrade integrations which are used by an integration policy. - Add `namespace` attribute to `elasticstack_kibana_synthetics_monitor` resource to support setting data stream namespace independently from `space_id` ([#1247](https://github.com/elastic/terraform-provider-elasticstack/pull/1247)) - 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)) ## [0.11.17] - 2025-07-21 diff --git a/docs/resources/fleet_agent_policy.md b/docs/resources/fleet_agent_policy.md index 9eb4d4ea7..d8252b5b2 100644 --- a/docs/resources/fleet_agent_policy.md +++ b/docs/resources/fleet_agent_policy.md @@ -51,6 +51,7 @@ resource "elasticstack_fleet_agent_policy" "test_policy" { - `download_source_id` (String) The identifier for the Elastic Agent binary download server. - `fleet_server_host_id` (String) The identifier for the Fleet server host. - `global_data_tags` (Attributes Map) User-defined data tags to apply to all inputs. Values can be strings (string_value) or numbers (number_value) but not both. Example -- key1 = {string_value = value1}, key2 = {number_value = 42} (see [below for nested schema](#nestedatt--global_data_tags)) +- `inactivity_timeout` (String) The inactivity timeout for the agent policy. If an agent does not report within this time period, it will be considered inactive. Supports duration strings (e.g., '30s', '2m', '1h'). - `monitor_logs` (Boolean) Enable collection of agent logs. - `monitor_metrics` (Boolean) Enable collection of agent metrics. - `monitoring_output_id` (String) The identifier for monitoring output. diff --git a/internal/fleet/agent_policy/models.go b/internal/fleet/agent_policy/models.go index 1fddf95cd..a87c3d092 100644 --- a/internal/fleet/agent_policy/models.go +++ b/internal/fleet/agent_policy/models.go @@ -4,9 +4,11 @@ import ( "context" "fmt" "slices" + "time" "github.com/elastic/terraform-provider-elasticstack/generated/kbapi" "github.com/elastic/terraform-provider-elasticstack/internal/utils" + "github.com/elastic/terraform-provider-elasticstack/internal/utils/customtypes" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" @@ -17,6 +19,7 @@ import ( type features struct { SupportsGlobalDataTags bool SupportsSupportsAgentless bool + SupportsInactivityTimeout bool } type globalDataTagsItemModel struct { @@ -25,21 +28,22 @@ type globalDataTagsItemModel struct { } type agentPolicyModel struct { - ID types.String `tfsdk:"id"` - PolicyID types.String `tfsdk:"policy_id"` - Name types.String `tfsdk:"name"` - Namespace types.String `tfsdk:"namespace"` - Description types.String `tfsdk:"description"` - DataOutputId types.String `tfsdk:"data_output_id"` - MonitoringOutputId types.String `tfsdk:"monitoring_output_id"` - FleetServerHostId types.String `tfsdk:"fleet_server_host_id"` - DownloadSourceId types.String `tfsdk:"download_source_id"` - MonitorLogs types.Bool `tfsdk:"monitor_logs"` - MonitorMetrics types.Bool `tfsdk:"monitor_metrics"` - SysMonitoring types.Bool `tfsdk:"sys_monitoring"` - SkipDestroy types.Bool `tfsdk:"skip_destroy"` - SupportsAgentless types.Bool `tfsdk:"supports_agentless"` - GlobalDataTags types.Map `tfsdk:"global_data_tags"` //> globalDataTagsModel + ID types.String `tfsdk:"id"` + PolicyID types.String `tfsdk:"policy_id"` + Name types.String `tfsdk:"name"` + Namespace types.String `tfsdk:"namespace"` + Description types.String `tfsdk:"description"` + DataOutputId types.String `tfsdk:"data_output_id"` + MonitoringOutputId types.String `tfsdk:"monitoring_output_id"` + FleetServerHostId types.String `tfsdk:"fleet_server_host_id"` + DownloadSourceId types.String `tfsdk:"download_source_id"` + MonitorLogs types.Bool `tfsdk:"monitor_logs"` + MonitorMetrics types.Bool `tfsdk:"monitor_metrics"` + SysMonitoring types.Bool `tfsdk:"sys_monitoring"` + SkipDestroy types.Bool `tfsdk:"skip_destroy"` + SupportsAgentless types.Bool `tfsdk:"supports_agentless"` + InactivityTimeout customtypes.Duration `tfsdk:"inactivity_timeout"` + GlobalDataTags types.Map `tfsdk:"global_data_tags"` //> globalDataTagsModel } func (model *agentPolicyModel) populateFromAPI(ctx context.Context, data *kbapi.AgentPolicy) diag.Diagnostics { @@ -73,6 +77,13 @@ func (model *agentPolicyModel) populateFromAPI(ctx context.Context, data *kbapi. model.Name = types.StringValue(data.Name) model.Namespace = types.StringValue(data.Namespace) model.SupportsAgentless = types.BoolPointerValue(data.SupportsAgentless) + if data.InactivityTimeout != nil { + // Convert seconds to duration string + d := time.Duration(*data.InactivityTimeout * float32(time.Second)).Truncate(time.Second) + model.InactivityTimeout = customtypes.NewDurationValue(d.String()) + } else { + model.InactivityTimeout = customtypes.NewDurationNull() + } if utils.Deref(data.GlobalDataTags) != nil { diags := diag.Diagnostics{} var map0 = make(map[string]globalDataTagsItemModel) @@ -162,16 +173,6 @@ func (model *agentPolicyModel) toAPICreateModel(ctx context.Context, feat featur monitoring = append(monitoring, kbapi.PostFleetAgentPoliciesJSONBodyMonitoringEnabledMetrics) } - if utils.IsKnown(model.SupportsAgentless) && !feat.SupportsSupportsAgentless { - return kbapi.PostFleetAgentPoliciesJSONRequestBody{}, diag.Diagnostics{ - diag.NewAttributeErrorDiagnostic( - path.Root("supports_agentless"), - "Unsupported Elasticsearch version", - fmt.Sprintf("Supports agentless is only supported in Elastic Stack %s and above", MinSupportsAgentlessVersion), - ), - } - } - body := kbapi.PostFleetAgentPoliciesJSONRequestBody{ DataOutputId: model.DataOutputId.ValueStringPointer(), Description: model.Description.ValueStringPointer(), @@ -182,7 +183,37 @@ func (model *agentPolicyModel) toAPICreateModel(ctx context.Context, feat featur MonitoringOutputId: model.MonitoringOutputId.ValueStringPointer(), Name: model.Name.ValueString(), Namespace: model.Namespace.ValueString(), - SupportsAgentless: model.SupportsAgentless.ValueBoolPointer(), + } + + if utils.IsKnown(model.SupportsAgentless) { + if !feat.SupportsSupportsAgentless { + return kbapi.PostFleetAgentPoliciesJSONRequestBody{}, diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("supports_agentless"), + "Unsupported Elasticsearch version", + fmt.Sprintf("Supports agentless is only supported in Elastic Stack %s and above", MinSupportsAgentlessVersion), + ), + } + } + body.SupportsAgentless = model.SupportsAgentless.ValueBoolPointer() + } + + if utils.IsKnown(model.InactivityTimeout) { + if !feat.SupportsInactivityTimeout { + return kbapi.PostFleetAgentPoliciesJSONRequestBody{}, diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("inactivity_timeout"), + "Unsupported Elasticsearch version", + fmt.Sprintf("Inactivity timeout is only supported in Elastic Stack %s and above", MinVersionInactivityTimeout), + ), + } + } + duration, diags := model.InactivityTimeout.Parse() + if diags.HasError() { + return kbapi.PostFleetAgentPoliciesJSONRequestBody{}, diags + } + seconds := float32(duration.Seconds()) + body.InactivityTimeout = &seconds } tags, diags := model.convertGlobalDataTags(ctx, feat) @@ -203,16 +234,6 @@ func (model *agentPolicyModel) toAPIUpdateModel(ctx context.Context, feat featur monitoring = append(monitoring, kbapi.Metrics) } - if utils.IsKnown(model.SupportsAgentless) && !feat.SupportsSupportsAgentless { - return kbapi.PutFleetAgentPoliciesAgentpolicyidJSONRequestBody{}, diag.Diagnostics{ - diag.NewAttributeErrorDiagnostic( - path.Root("supports_agentless"), - "Unsupported Elasticsearch version", - fmt.Sprintf("Supports agentless is only supported in Elastic Stack %s and above", MinSupportsAgentlessVersion), - ), - } - } - body := kbapi.PutFleetAgentPoliciesAgentpolicyidJSONRequestBody{ DataOutputId: model.DataOutputId.ValueStringPointer(), Description: model.Description.ValueStringPointer(), @@ -222,7 +243,37 @@ func (model *agentPolicyModel) toAPIUpdateModel(ctx context.Context, feat featur MonitoringOutputId: model.MonitoringOutputId.ValueStringPointer(), Name: model.Name.ValueString(), Namespace: model.Namespace.ValueString(), - SupportsAgentless: model.SupportsAgentless.ValueBoolPointer(), + } + + if utils.IsKnown(model.SupportsAgentless) { + if !feat.SupportsSupportsAgentless { + return kbapi.PutFleetAgentPoliciesAgentpolicyidJSONRequestBody{}, diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("supports_agentless"), + "Unsupported Elasticsearch version", + fmt.Sprintf("Supports agentless is only supported in Elastic Stack %s and above", MinSupportsAgentlessVersion), + ), + } + } + body.SupportsAgentless = model.SupportsAgentless.ValueBoolPointer() + } + + if utils.IsKnown(model.InactivityTimeout) { + if !feat.SupportsInactivityTimeout { + return kbapi.PutFleetAgentPoliciesAgentpolicyidJSONRequestBody{}, diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("inactivity_timeout"), + "Unsupported Elasticsearch version", + fmt.Sprintf("Inactivity timeout is only supported in Elastic Stack %s and above", MinVersionInactivityTimeout), + ), + } + } + duration, diags := model.InactivityTimeout.Parse() + if diags.HasError() { + return kbapi.PutFleetAgentPoliciesAgentpolicyidJSONRequestBody{}, diags + } + seconds := float32(duration.Seconds()) + body.InactivityTimeout = &seconds } tags, diags := model.convertGlobalDataTags(ctx, feat) diff --git a/internal/fleet/agent_policy/resource.go b/internal/fleet/agent_policy/resource.go index 34e4a3cf4..fbeda8d50 100644 --- a/internal/fleet/agent_policy/resource.go +++ b/internal/fleet/agent_policy/resource.go @@ -21,6 +21,7 @@ var ( var ( MinVersionGlobalDataTags = version.Must(version.NewVersion("8.15.0")) MinSupportsAgentlessVersion = version.Must(version.NewVersion("8.15.0")) + MinVersionInactivityTimeout = version.Must(version.NewVersion("8.7.0")) ) // NewResource is a helper function to simplify the provider implementation. @@ -57,8 +58,14 @@ func (r *agentPolicyResource) buildFeatures(ctx context.Context) (features, diag return features{}, utils.FrameworkDiagsFromSDK(diags) } + supportsInactivityTimeout, diags := r.client.EnforceMinVersion(ctx, MinVersionInactivityTimeout) + if diags.HasError() { + return features{}, utils.FrameworkDiagsFromSDK(diags) + } + return features{ SupportsGlobalDataTags: supportsGDT, SupportsSupportsAgentless: supportsSupportsAgentless, + SupportsInactivityTimeout: supportsInactivityTimeout, }, nil } diff --git a/internal/fleet/agent_policy/resource_test.go b/internal/fleet/agent_policy/resource_test.go index 8f90217f4..acd184210 100644 --- a/internal/fleet/agent_policy/resource_test.go +++ b/internal/fleet/agent_policy/resource_test.go @@ -143,6 +143,19 @@ func TestAccResourceAgentPolicy(t *testing.T) { ImportStateVerify: true, ImportStateVerifyIgnore: []string{"skip_destroy"}, }, + { + SkipFunc: versionutils.CheckIfVersionIsUnsupported(agent_policy.MinVersionInactivityTimeout), + Config: testAccResourceAgentPolicyCreateWithInactivityTimeout(policyName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_fleet_agent_policy.test_policy", "name", fmt.Sprintf("Policy %s", policyName)), + resource.TestCheckResourceAttr("elasticstack_fleet_agent_policy.test_policy", "namespace", "default"), + resource.TestCheckResourceAttr("elasticstack_fleet_agent_policy.test_policy", "description", "Test Agent Policy with Inactivity Timeout"), + resource.TestCheckResourceAttr("elasticstack_fleet_agent_policy.test_policy", "monitor_logs", "true"), + resource.TestCheckResourceAttr("elasticstack_fleet_agent_policy.test_policy", "monitor_metrics", "false"), + resource.TestCheckResourceAttr("elasticstack_fleet_agent_policy.test_policy", "skip_destroy", "false"), + resource.TestCheckResourceAttr("elasticstack_fleet_agent_policy.test_policy", "inactivity_timeout", "2m"), + ), + }, { SkipFunc: versionutils.CheckIfVersionIsUnsupported(agent_policy.MinVersionGlobalDataTags), Config: testAccResourceAgentPolicyCreateWithGlobalDataTags(policyNameGlobalDataTags, false), @@ -295,6 +308,30 @@ data "elasticstack_fleet_enrollment_tokens" "test_policy" { `, fmt.Sprintf("Policy %s", id), skipDestroy) } +func testAccResourceAgentPolicyCreateWithInactivityTimeout(id string) string { + return fmt.Sprintf(` +provider "elasticstack" { + elasticsearch {} + kibana {} +} + +resource "elasticstack_fleet_agent_policy" "test_policy" { + name = "%s" + namespace = "default" + description = "Test Agent Policy with Inactivity Timeout" + monitor_logs = true + monitor_metrics = false + skip_destroy = false + inactivity_timeout = "2m" +} + +data "elasticstack_fleet_enrollment_tokens" "test_policy" { + policy_id = elasticstack_fleet_agent_policy.test_policy.policy_id +} + +`, fmt.Sprintf("Policy %s", id)) +} + func testAccResourceAgentPolicyCreateWithBadGlobalDataTags(id string, skipDestroy bool) string { return fmt.Sprintf(` provider "elasticstack" { diff --git a/internal/fleet/agent_policy/schema.go b/internal/fleet/agent_policy/schema.go index f1c531a29..be49763ef 100644 --- a/internal/fleet/agent_policy/schema.go +++ b/internal/fleet/agent_policy/schema.go @@ -3,6 +3,7 @@ package agent_policy import ( "context" + "github.com/elastic/terraform-provider-elasticstack/internal/utils/customtypes" "github.com/hashicorp/terraform-plugin-framework-validators/float32validator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/attr" @@ -97,6 +98,12 @@ func getSchema() schema.Schema { boolplanmodifier.RequiresReplace(), }, }, + "inactivity_timeout": schema.StringAttribute{ + Description: "The inactivity timeout for the agent policy. If an agent does not report within this time period, it will be considered inactive. Supports duration strings (e.g., '30s', '2m', '1h').", + Computed: true, + Optional: true, + CustomType: customtypes.DurationType{}, + }, "global_data_tags": schema.MapNestedAttribute{ Description: "User-defined data tags to apply to all inputs. Values can be strings (string_value) or numbers (number_value) but not both. Example -- key1 = {string_value = value1}, key2 = {number_value = 42}", NestedObject: schema.NestedAttributeObject{ diff --git a/internal/fleet/agent_policy/version_test.go b/internal/fleet/agent_policy/version_test.go new file mode 100644 index 000000000..8cdc0d133 --- /dev/null +++ b/internal/fleet/agent_policy/version_test.go @@ -0,0 +1,106 @@ +package agent_policy + +import ( + "context" + "github.com/elastic/terraform-provider-elasticstack/internal/utils/customtypes" + "github.com/hashicorp/go-version" + "github.com/hashicorp/terraform-plugin-framework/types" + "testing" +) + +func TestMinVersionInactivityTimeout(t *testing.T) { + // Test that the MinVersionInactivityTimeout constant is set correctly + expected := "8.7.0" + actual := MinVersionInactivityTimeout.String() + if actual != expected { + t.Errorf("Expected MinVersionInactivityTimeout to be '%s', got '%s'", expected, actual) + } + + // Test version comparison - should be greater than 8.6.0 + olderVersion := version.Must(version.NewVersion("8.6.0")) + if MinVersionInactivityTimeout.LessThan(olderVersion) { + t.Errorf("MinVersionInactivityTimeout (%s) should be greater than %s", MinVersionInactivityTimeout.String(), olderVersion.String()) + } + + // Test version comparison - should be less than 8.8.0 + newerVersion := version.Must(version.NewVersion("8.8.0")) + if MinVersionInactivityTimeout.GreaterThan(newerVersion) { + t.Errorf("MinVersionInactivityTimeout (%s) should be less than %s", MinVersionInactivityTimeout.String(), newerVersion.String()) + } +} + +func TestInactivityTimeoutVersionValidation(t *testing.T) { + ctx := context.Background() + + // Test case where inactivity_timeout is not supported (older version) + model := &agentPolicyModel{ + Name: types.StringValue("test"), + Namespace: types.StringValue("default"), + InactivityTimeout: customtypes.NewDurationValue("2m"), + } + + // Create features with inactivity timeout NOT supported + feat := features{ + SupportsInactivityTimeout: false, + } + + // Test toAPICreateModel - should return error when inactivity_timeout is used but not supported + _, diags := model.toAPICreateModel(ctx, feat) + if !diags.HasError() { + t.Error("Expected error when using inactivity_timeout on unsupported version, but got none") + } + + // Check that the error message contains the expected text + found := false + for _, diag := range diags { + if diag.Summary() == "Unsupported Elasticsearch version" { + found = true + break + } + } + if !found { + t.Error("Expected 'Unsupported Elasticsearch version' error, but didn't find it") + } + + // Test toAPIUpdateModel - should return error when inactivity_timeout is used but not supported + _, diags = model.toAPIUpdateModel(ctx, feat) + if !diags.HasError() { + t.Error("Expected error when using inactivity_timeout on unsupported version in update, but got none") + } + + // Test case where inactivity_timeout IS supported (newer version) + featSupported := features{ + SupportsInactivityTimeout: true, + } + + // Test toAPICreateModel - should NOT return error when inactivity_timeout is supported + _, diags = model.toAPICreateModel(ctx, featSupported) + if diags.HasError() { + t.Errorf("Did not expect error when using inactivity_timeout on supported version: %v", diags) + } + + // Test toAPIUpdateModel - should NOT return error when inactivity_timeout is supported + _, diags = model.toAPIUpdateModel(ctx, featSupported) + if diags.HasError() { + t.Errorf("Did not expect error when using inactivity_timeout on supported version in update: %v", diags) + } + + // Test case where inactivity_timeout is not set (should not cause validation errors) + modelWithoutTimeout := &agentPolicyModel{ + Name: types.StringValue("test"), + Namespace: types.StringValue("default"), + // InactivityTimeout is not set (null/unknown) + } + + // Test toAPICreateModel - should NOT return error when inactivity_timeout is not set, even on unsupported version + _, diags = modelWithoutTimeout.toAPICreateModel(ctx, feat) + if diags.HasError() { + t.Errorf("Did not expect error when inactivity_timeout is not set: %v", diags) + } + + // Test toAPIUpdateModel - should NOT return error when inactivity_timeout is not set, even on unsupported version + _, diags = modelWithoutTimeout.toAPIUpdateModel(ctx, feat) + if diags.HasError() { + t.Errorf("Did not expect error when inactivity_timeout is not set in update: %v", diags) + } +}