diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bfc460cd..32afa2c5c 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)) +- Add support for `unenrollment_timeout` in `elasticstack_fleet_agent_policy` ([#1169](https://github.com/elastic/terraform-provider-elasticstack/issues/1169)) ## [0.11.17] - 2025-07-21 diff --git a/docs/resources/fleet_agent_policy.md b/docs/resources/fleet_agent_policy.md index d8252b5b2..dbf85c0fa 100644 --- a/docs/resources/fleet_agent_policy.md +++ b/docs/resources/fleet_agent_policy.md @@ -59,6 +59,7 @@ resource "elasticstack_fleet_agent_policy" "test_policy" { - `skip_destroy` (Boolean) Set to true if you do not wish the agent policy to be deleted at destroy time, and instead just remove the agent policy from the Terraform state. - `supports_agentless` (Boolean) Set to true to enable agentless data collection. - `sys_monitoring` (Boolean) Enable collection of system logs and metrics. +- `unenrollment_timeout` (String) The unenrollment timeout for the agent policy. If an agent is inactive for this period, it will be automatically unenrolled. Supports duration strings (e.g., '30s', '2m', '1h'). ### Read-Only diff --git a/internal/fleet/agent_policy/models.go b/internal/fleet/agent_policy/models.go index 4997d08ba..6c44b35e7 100644 --- a/internal/fleet/agent_policy/models.go +++ b/internal/fleet/agent_policy/models.go @@ -17,9 +17,10 @@ import ( ) type features struct { - SupportsGlobalDataTags bool - SupportsSupportsAgentless bool - SupportsInactivityTimeout bool + SupportsGlobalDataTags bool + SupportsSupportsAgentless bool + SupportsInactivityTimeout bool + SupportsUnenrollmentTimeout bool } type globalDataTagsItemModel struct { @@ -28,22 +29,23 @@ 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"` - InactivityTimeout customtypes.Duration `tfsdk:"inactivity_timeout"` - 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"` + UnenrollmentTimeout customtypes.Duration `tfsdk:"unenrollment_timeout"` + GlobalDataTags types.Map `tfsdk:"global_data_tags"` //> globalDataTagsModel } func (model *agentPolicyModel) populateFromAPI(ctx context.Context, data *kbapi.AgentPolicy) diag.Diagnostics { @@ -79,11 +81,20 @@ func (model *agentPolicyModel) populateFromAPI(ctx context.Context, data *kbapi. 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()) + seconds := int64(*data.InactivityTimeout) + d := time.Duration(seconds) * time.Second + model.InactivityTimeout = customtypes.NewDurationValue(d.Truncate(time.Second).String()) } else { model.InactivityTimeout = customtypes.NewDurationNull() } + if data.UnenrollTimeout != nil { + // Convert seconds to duration string + seconds := int64(*data.UnenrollTimeout) + d := time.Duration(seconds) * time.Second + model.UnenrollmentTimeout = customtypes.NewDurationValue(d.Truncate(time.Second).String()) + } else { + model.UnenrollmentTimeout = customtypes.NewDurationNull() + } if utils.Deref(data.GlobalDataTags) != nil { diags := diag.Diagnostics{} var map0 = make(map[string]globalDataTagsItemModel) @@ -216,6 +227,24 @@ func (model *agentPolicyModel) toAPICreateModel(ctx context.Context, feat featur body.InactivityTimeout = &seconds } + if utils.IsKnown(model.UnenrollmentTimeout) { + if !feat.SupportsUnenrollmentTimeout { + return kbapi.PostFleetAgentPoliciesJSONRequestBody{}, diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("unenrollment_timeout"), + "Unsupported Elasticsearch version", + fmt.Sprintf("Unenrollment timeout is only supported in Elastic Stack %s and above", MinVersionUnenrollmentTimeout), + ), + } + } + duration, diags := model.UnenrollmentTimeout.Parse() + if diags.HasError() { + return kbapi.PostFleetAgentPoliciesJSONRequestBody{}, diags + } + seconds := float32(duration.Seconds()) + body.UnenrollTimeout = &seconds + } + tags, diags := model.convertGlobalDataTags(ctx, feat) if diags.HasError() { return kbapi.PostFleetAgentPoliciesJSONRequestBody{}, diags @@ -276,6 +305,24 @@ func (model *agentPolicyModel) toAPIUpdateModel(ctx context.Context, feat featur body.InactivityTimeout = &seconds } + if utils.IsKnown(model.UnenrollmentTimeout) { + if !feat.SupportsUnenrollmentTimeout { + return kbapi.PutFleetAgentPoliciesAgentpolicyidJSONRequestBody{}, diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("unenrollment_timeout"), + "Unsupported Elasticsearch version", + fmt.Sprintf("Unenrollment timeout is only supported in Elastic Stack %s and above", MinVersionUnenrollmentTimeout), + ), + } + } + duration, diags := model.UnenrollmentTimeout.Parse() + if diags.HasError() { + return kbapi.PutFleetAgentPoliciesAgentpolicyidJSONRequestBody{}, diags + } + seconds := float32(duration.Seconds()) + body.UnenrollTimeout = &seconds + } + tags, diags := model.convertGlobalDataTags(ctx, feat) if diags.HasError() { return kbapi.PutFleetAgentPoliciesAgentpolicyidJSONRequestBody{}, diags diff --git a/internal/fleet/agent_policy/resource.go b/internal/fleet/agent_policy/resource.go index fbeda8d50..8854fde9f 100644 --- a/internal/fleet/agent_policy/resource.go +++ b/internal/fleet/agent_policy/resource.go @@ -19,9 +19,10 @@ 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")) + MinVersionGlobalDataTags = version.Must(version.NewVersion("8.15.0")) + MinSupportsAgentlessVersion = version.Must(version.NewVersion("8.15.0")) + MinVersionInactivityTimeout = version.Must(version.NewVersion("8.7.0")) + MinVersionUnenrollmentTimeout = version.Must(version.NewVersion("8.15.0")) ) // NewResource is a helper function to simplify the provider implementation. @@ -63,9 +64,15 @@ func (r *agentPolicyResource) buildFeatures(ctx context.Context) (features, diag return features{}, utils.FrameworkDiagsFromSDK(diags) } + supportsUnenrollmentTimeout, diags := r.client.EnforceMinVersion(ctx, MinVersionUnenrollmentTimeout) + if diags.HasError() { + return features{}, utils.FrameworkDiagsFromSDK(diags) + } + return features{ - SupportsGlobalDataTags: supportsGDT, - SupportsSupportsAgentless: supportsSupportsAgentless, - SupportsInactivityTimeout: supportsInactivityTimeout, + SupportsGlobalDataTags: supportsGDT, + SupportsSupportsAgentless: supportsSupportsAgentless, + SupportsInactivityTimeout: supportsInactivityTimeout, + SupportsUnenrollmentTimeout: supportsUnenrollmentTimeout, }, nil } diff --git a/internal/fleet/agent_policy/resource_test.go b/internal/fleet/agent_policy/resource_test.go index acd184210..085fa2dd2 100644 --- a/internal/fleet/agent_policy/resource_test.go +++ b/internal/fleet/agent_policy/resource_test.go @@ -156,6 +156,33 @@ func TestAccResourceAgentPolicy(t *testing.T) { resource.TestCheckResourceAttr("elasticstack_fleet_agent_policy.test_policy", "inactivity_timeout", "2m"), ), }, + { + SkipFunc: versionutils.CheckIfVersionIsUnsupported(agent_policy.MinVersionUnenrollmentTimeout), + Config: testAccResourceAgentPolicyCreateWithUnenrollmentTimeout(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 Unenrollment 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", "unenrollment_timeout", "300s"), + ), + }, + { + SkipFunc: versionutils.CheckIfVersionIsUnsupported(agent_policy.MinVersionUnenrollmentTimeout), + Config: testAccResourceAgentPolicyUpdateWithTimeouts(policyName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_fleet_agent_policy.test_policy", "name", fmt.Sprintf("Updated 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 Both Timeouts"), + resource.TestCheckResourceAttr("elasticstack_fleet_agent_policy.test_policy", "monitor_logs", "false"), + resource.TestCheckResourceAttr("elasticstack_fleet_agent_policy.test_policy", "monitor_metrics", "true"), + resource.TestCheckResourceAttr("elasticstack_fleet_agent_policy.test_policy", "skip_destroy", "false"), + resource.TestCheckResourceAttr("elasticstack_fleet_agent_policy.test_policy", "inactivity_timeout", "120s"), + resource.TestCheckResourceAttr("elasticstack_fleet_agent_policy.test_policy", "unenrollment_timeout", "900s"), + ), + }, { SkipFunc: versionutils.CheckIfVersionIsUnsupported(agent_policy.MinVersionGlobalDataTags), Config: testAccResourceAgentPolicyCreateWithGlobalDataTags(policyNameGlobalDataTags, false), @@ -332,6 +359,30 @@ data "elasticstack_fleet_enrollment_tokens" "test_policy" { `, fmt.Sprintf("Policy %s", id)) } +func testAccResourceAgentPolicyCreateWithUnenrollmentTimeout(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 Unenrollment Timeout" + monitor_logs = true + monitor_metrics = false + skip_destroy = false + unenrollment_timeout = "300s" +} + +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" { @@ -509,3 +560,28 @@ func checkResourceAgentPolicySkipDestroy(s *terraform.State) error { } return nil } + +func testAccResourceAgentPolicyUpdateWithTimeouts(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 Both Timeouts" + monitor_logs = false + monitor_metrics = true + skip_destroy = false + inactivity_timeout = "120s" + unenrollment_timeout = "900s" +} + +data "elasticstack_fleet_enrollment_tokens" "test_policy" { + policy_id = elasticstack_fleet_agent_policy.test_policy.policy_id +} + +`, fmt.Sprintf("Updated Policy %s", id)) +} diff --git a/internal/fleet/agent_policy/schema.go b/internal/fleet/agent_policy/schema.go index be49763ef..dd98e07c0 100644 --- a/internal/fleet/agent_policy/schema.go +++ b/internal/fleet/agent_policy/schema.go @@ -104,6 +104,12 @@ func getSchema() schema.Schema { Optional: true, CustomType: customtypes.DurationType{}, }, + "unenrollment_timeout": schema.StringAttribute{ + Description: "The unenrollment timeout for the agent policy. If an agent is inactive for this period, it will be automatically unenrolled. 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 index 8cdc0d133..98d81195a 100644 --- a/internal/fleet/agent_policy/version_test.go +++ b/internal/fleet/agent_policy/version_test.go @@ -29,6 +29,27 @@ func TestMinVersionInactivityTimeout(t *testing.T) { } } +func TestMinVersionUnenrollmentTimeout(t *testing.T) { + // Test that the MinVersionUnenrollmentTimeout constant is set correctly + expected := "8.15.0" + actual := MinVersionUnenrollmentTimeout.String() + if actual != expected { + t.Errorf("Expected MinVersionUnenrollmentTimeout to be '%s', got '%s'", expected, actual) + } + + // Test version comparison - should be greater than 8.14.0 + olderVersion := version.Must(version.NewVersion("8.14.0")) + if MinVersionUnenrollmentTimeout.LessThan(olderVersion) { + t.Errorf("MinVersionUnenrollmentTimeout (%s) should be greater than %s", MinVersionUnenrollmentTimeout.String(), olderVersion.String()) + } + + // Test version comparison - should be less than 8.16.0 + newerVersion := version.Must(version.NewVersion("8.16.0")) + if MinVersionUnenrollmentTimeout.GreaterThan(newerVersion) { + t.Errorf("MinVersionUnenrollmentTimeout (%s) should be less than %s", MinVersionUnenrollmentTimeout.String(), newerVersion.String()) + } +} + func TestInactivityTimeoutVersionValidation(t *testing.T) { ctx := context.Background() @@ -104,3 +125,79 @@ func TestInactivityTimeoutVersionValidation(t *testing.T) { t.Errorf("Did not expect error when inactivity_timeout is not set in update: %v", diags) } } + +func TestUnenrollmentTimeoutVersionValidation(t *testing.T) { + ctx := context.Background() + + // Test case where unenrollment_timeout is not supported (older version) + model := &agentPolicyModel{ + Name: types.StringValue("test"), + Namespace: types.StringValue("default"), + UnenrollmentTimeout: customtypes.NewDurationValue("5m"), + } + + // Create features with unenrollment timeout NOT supported + feat := features{ + SupportsUnenrollmentTimeout: false, + } + + // Test toAPICreateModel - should return error when unenrollment_timeout is used but not supported + _, diags := model.toAPICreateModel(ctx, feat) + if !diags.HasError() { + t.Error("Expected error when using unenrollment_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 unenrollment_timeout is used but not supported + _, diags = model.toAPIUpdateModel(ctx, feat) + if !diags.HasError() { + t.Error("Expected error when using unenrollment_timeout on unsupported version in update, but got none") + } + + // Test case where unenrollment_timeout IS supported (newer version) + featSupported := features{ + SupportsUnenrollmentTimeout: true, + } + + // Test toAPICreateModel - should NOT return error when unenrollment_timeout is supported + _, diags = model.toAPICreateModel(ctx, featSupported) + if diags.HasError() { + t.Errorf("Did not expect error when using unenrollment_timeout on supported version: %v", diags) + } + + // Test toAPIUpdateModel - should NOT return error when unenrollment_timeout is supported + _, diags = model.toAPIUpdateModel(ctx, featSupported) + if diags.HasError() { + t.Errorf("Did not expect error when using unenrollment_timeout on supported version in update: %v", diags) + } + + // Test case where unenrollment_timeout is not set (should not cause validation errors) + modelWithoutTimeout := &agentPolicyModel{ + Name: types.StringValue("test"), + Namespace: types.StringValue("default"), + // UnenrollmentTimeout is not set (null/unknown) + } + + // Test toAPICreateModel - should NOT return error when unenrollment_timeout is not set, even on unsupported version + _, diags = modelWithoutTimeout.toAPICreateModel(ctx, feat) + if diags.HasError() { + t.Errorf("Did not expect error when unenrollment_timeout is not set: %v", diags) + } + + // Test toAPIUpdateModel - should NOT return error when unenrollment_timeout is not set, even on unsupported version + _, diags = modelWithoutTimeout.toAPIUpdateModel(ctx, feat) + if diags.HasError() { + t.Errorf("Did not expect error when unenrollment_timeout is not set in update: %v", diags) + } +}