Skip to content

Commit 6487901

Browse files
Copilottobio
andauthored
[Feature] Add unenrollment_timeout parameter to Fleet Agent Policy resource (elastic#1306)
* Initial plan * Add unenrollment_timeout attribute to Fleet Agent Policy resource Co-authored-by: tobio <[email protected]> * Update documentation for unenrollment_timeout attribute * Fix floating-point precision issue in duration conversion Co-authored-by: tobio <[email protected]> * Fix confusing duration conversion logic for timeout attributes Clean up duration conversion in populateFromAPI to make it more readable and consistent. Apply truncation to ensure precision consistency for both InactivityTimeout and UnenrollmentTimeout attributes. Addresses review feedback on confusing duration conversion pattern. Co-authored-by: tobio <[email protected]> * Add changelog entry for unenrollment_timeout feature Co-authored-by: tobio <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: tobio <[email protected]>
1 parent 1cc6bd6 commit 6487901

File tree

7 files changed

+262
-27
lines changed

7 files changed

+262
-27
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
- Migrate `elasticstack_kibana_action_connector` to the Terraform plugin framework ([#1269](https://github.com/elastic/terraform-provider-elasticstack/pull/1269))
1414
- Migrate `elasticstack_elasticsearch_security_role_mapping` resource and data source to Terraform Plugin Framework ([#1279](https://github.com/elastic/terraform-provider-elasticstack/pull/1279))
1515
- Add support for `inactivity_timeout` in `elasticstack_fleet_agent_policy` ([#641](https://github.com/elastic/terraform-provider-elasticstack/issues/641))
16+
- Add support for `unenrollment_timeout` in `elasticstack_fleet_agent_policy` ([#1169](https://github.com/elastic/terraform-provider-elasticstack/issues/1169))
1617

1718
## [0.11.17] - 2025-07-21
1819

docs/resources/fleet_agent_policy.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ resource "elasticstack_fleet_agent_policy" "test_policy" {
5959
- `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.
6060
- `supports_agentless` (Boolean) Set to true to enable agentless data collection.
6161
- `sys_monitoring` (Boolean) Enable collection of system logs and metrics.
62+
- `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').
6263

6364
### Read-Only
6465

internal/fleet/agent_policy/models.go

Lines changed: 68 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@ import (
1717
)
1818

1919
type features struct {
20-
SupportsGlobalDataTags bool
21-
SupportsSupportsAgentless bool
22-
SupportsInactivityTimeout bool
20+
SupportsGlobalDataTags bool
21+
SupportsSupportsAgentless bool
22+
SupportsInactivityTimeout bool
23+
SupportsUnenrollmentTimeout bool
2324
}
2425

2526
type globalDataTagsItemModel struct {
@@ -28,22 +29,23 @@ type globalDataTagsItemModel struct {
2829
}
2930

3031
type agentPolicyModel struct {
31-
ID types.String `tfsdk:"id"`
32-
PolicyID types.String `tfsdk:"policy_id"`
33-
Name types.String `tfsdk:"name"`
34-
Namespace types.String `tfsdk:"namespace"`
35-
Description types.String `tfsdk:"description"`
36-
DataOutputId types.String `tfsdk:"data_output_id"`
37-
MonitoringOutputId types.String `tfsdk:"monitoring_output_id"`
38-
FleetServerHostId types.String `tfsdk:"fleet_server_host_id"`
39-
DownloadSourceId types.String `tfsdk:"download_source_id"`
40-
MonitorLogs types.Bool `tfsdk:"monitor_logs"`
41-
MonitorMetrics types.Bool `tfsdk:"monitor_metrics"`
42-
SysMonitoring types.Bool `tfsdk:"sys_monitoring"`
43-
SkipDestroy types.Bool `tfsdk:"skip_destroy"`
44-
SupportsAgentless types.Bool `tfsdk:"supports_agentless"`
45-
InactivityTimeout customtypes.Duration `tfsdk:"inactivity_timeout"`
46-
GlobalDataTags types.Map `tfsdk:"global_data_tags"` //> globalDataTagsModel
32+
ID types.String `tfsdk:"id"`
33+
PolicyID types.String `tfsdk:"policy_id"`
34+
Name types.String `tfsdk:"name"`
35+
Namespace types.String `tfsdk:"namespace"`
36+
Description types.String `tfsdk:"description"`
37+
DataOutputId types.String `tfsdk:"data_output_id"`
38+
MonitoringOutputId types.String `tfsdk:"monitoring_output_id"`
39+
FleetServerHostId types.String `tfsdk:"fleet_server_host_id"`
40+
DownloadSourceId types.String `tfsdk:"download_source_id"`
41+
MonitorLogs types.Bool `tfsdk:"monitor_logs"`
42+
MonitorMetrics types.Bool `tfsdk:"monitor_metrics"`
43+
SysMonitoring types.Bool `tfsdk:"sys_monitoring"`
44+
SkipDestroy types.Bool `tfsdk:"skip_destroy"`
45+
SupportsAgentless types.Bool `tfsdk:"supports_agentless"`
46+
InactivityTimeout customtypes.Duration `tfsdk:"inactivity_timeout"`
47+
UnenrollmentTimeout customtypes.Duration `tfsdk:"unenrollment_timeout"`
48+
GlobalDataTags types.Map `tfsdk:"global_data_tags"` //> globalDataTagsModel
4749
}
4850

4951
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.
7981
model.SupportsAgentless = types.BoolPointerValue(data.SupportsAgentless)
8082
if data.InactivityTimeout != nil {
8183
// Convert seconds to duration string
82-
d := time.Duration(*data.InactivityTimeout * float32(time.Second)).Truncate(time.Second)
83-
model.InactivityTimeout = customtypes.NewDurationValue(d.String())
84+
seconds := int64(*data.InactivityTimeout)
85+
d := time.Duration(seconds) * time.Second
86+
model.InactivityTimeout = customtypes.NewDurationValue(d.Truncate(time.Second).String())
8487
} else {
8588
model.InactivityTimeout = customtypes.NewDurationNull()
8689
}
90+
if data.UnenrollTimeout != nil {
91+
// Convert seconds to duration string
92+
seconds := int64(*data.UnenrollTimeout)
93+
d := time.Duration(seconds) * time.Second
94+
model.UnenrollmentTimeout = customtypes.NewDurationValue(d.Truncate(time.Second).String())
95+
} else {
96+
model.UnenrollmentTimeout = customtypes.NewDurationNull()
97+
}
8798
if utils.Deref(data.GlobalDataTags) != nil {
8899
diags := diag.Diagnostics{}
89100
var map0 = make(map[string]globalDataTagsItemModel)
@@ -216,6 +227,24 @@ func (model *agentPolicyModel) toAPICreateModel(ctx context.Context, feat featur
216227
body.InactivityTimeout = &seconds
217228
}
218229

230+
if utils.IsKnown(model.UnenrollmentTimeout) {
231+
if !feat.SupportsUnenrollmentTimeout {
232+
return kbapi.PostFleetAgentPoliciesJSONRequestBody{}, diag.Diagnostics{
233+
diag.NewAttributeErrorDiagnostic(
234+
path.Root("unenrollment_timeout"),
235+
"Unsupported Elasticsearch version",
236+
fmt.Sprintf("Unenrollment timeout is only supported in Elastic Stack %s and above", MinVersionUnenrollmentTimeout),
237+
),
238+
}
239+
}
240+
duration, diags := model.UnenrollmentTimeout.Parse()
241+
if diags.HasError() {
242+
return kbapi.PostFleetAgentPoliciesJSONRequestBody{}, diags
243+
}
244+
seconds := float32(duration.Seconds())
245+
body.UnenrollTimeout = &seconds
246+
}
247+
219248
tags, diags := model.convertGlobalDataTags(ctx, feat)
220249
if diags.HasError() {
221250
return kbapi.PostFleetAgentPoliciesJSONRequestBody{}, diags
@@ -276,6 +305,24 @@ func (model *agentPolicyModel) toAPIUpdateModel(ctx context.Context, feat featur
276305
body.InactivityTimeout = &seconds
277306
}
278307

308+
if utils.IsKnown(model.UnenrollmentTimeout) {
309+
if !feat.SupportsUnenrollmentTimeout {
310+
return kbapi.PutFleetAgentPoliciesAgentpolicyidJSONRequestBody{}, diag.Diagnostics{
311+
diag.NewAttributeErrorDiagnostic(
312+
path.Root("unenrollment_timeout"),
313+
"Unsupported Elasticsearch version",
314+
fmt.Sprintf("Unenrollment timeout is only supported in Elastic Stack %s and above", MinVersionUnenrollmentTimeout),
315+
),
316+
}
317+
}
318+
duration, diags := model.UnenrollmentTimeout.Parse()
319+
if diags.HasError() {
320+
return kbapi.PutFleetAgentPoliciesAgentpolicyidJSONRequestBody{}, diags
321+
}
322+
seconds := float32(duration.Seconds())
323+
body.UnenrollTimeout = &seconds
324+
}
325+
279326
tags, diags := model.convertGlobalDataTags(ctx, feat)
280327
if diags.HasError() {
281328
return kbapi.PutFleetAgentPoliciesAgentpolicyidJSONRequestBody{}, diags

internal/fleet/agent_policy/resource.go

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@ var (
1919
)
2020

2121
var (
22-
MinVersionGlobalDataTags = version.Must(version.NewVersion("8.15.0"))
23-
MinSupportsAgentlessVersion = version.Must(version.NewVersion("8.15.0"))
24-
MinVersionInactivityTimeout = version.Must(version.NewVersion("8.7.0"))
22+
MinVersionGlobalDataTags = version.Must(version.NewVersion("8.15.0"))
23+
MinSupportsAgentlessVersion = version.Must(version.NewVersion("8.15.0"))
24+
MinVersionInactivityTimeout = version.Must(version.NewVersion("8.7.0"))
25+
MinVersionUnenrollmentTimeout = version.Must(version.NewVersion("8.15.0"))
2526
)
2627

2728
// NewResource is a helper function to simplify the provider implementation.
@@ -63,9 +64,15 @@ func (r *agentPolicyResource) buildFeatures(ctx context.Context) (features, diag
6364
return features{}, utils.FrameworkDiagsFromSDK(diags)
6465
}
6566

67+
supportsUnenrollmentTimeout, diags := r.client.EnforceMinVersion(ctx, MinVersionUnenrollmentTimeout)
68+
if diags.HasError() {
69+
return features{}, utils.FrameworkDiagsFromSDK(diags)
70+
}
71+
6672
return features{
67-
SupportsGlobalDataTags: supportsGDT,
68-
SupportsSupportsAgentless: supportsSupportsAgentless,
69-
SupportsInactivityTimeout: supportsInactivityTimeout,
73+
SupportsGlobalDataTags: supportsGDT,
74+
SupportsSupportsAgentless: supportsSupportsAgentless,
75+
SupportsInactivityTimeout: supportsInactivityTimeout,
76+
SupportsUnenrollmentTimeout: supportsUnenrollmentTimeout,
7077
}, nil
7178
}

internal/fleet/agent_policy/resource_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,33 @@ func TestAccResourceAgentPolicy(t *testing.T) {
156156
resource.TestCheckResourceAttr("elasticstack_fleet_agent_policy.test_policy", "inactivity_timeout", "2m"),
157157
),
158158
},
159+
{
160+
SkipFunc: versionutils.CheckIfVersionIsUnsupported(agent_policy.MinVersionUnenrollmentTimeout),
161+
Config: testAccResourceAgentPolicyCreateWithUnenrollmentTimeout(policyName),
162+
Check: resource.ComposeTestCheckFunc(
163+
resource.TestCheckResourceAttr("elasticstack_fleet_agent_policy.test_policy", "name", fmt.Sprintf("Policy %s", policyName)),
164+
resource.TestCheckResourceAttr("elasticstack_fleet_agent_policy.test_policy", "namespace", "default"),
165+
resource.TestCheckResourceAttr("elasticstack_fleet_agent_policy.test_policy", "description", "Test Agent Policy with Unenrollment Timeout"),
166+
resource.TestCheckResourceAttr("elasticstack_fleet_agent_policy.test_policy", "monitor_logs", "true"),
167+
resource.TestCheckResourceAttr("elasticstack_fleet_agent_policy.test_policy", "monitor_metrics", "false"),
168+
resource.TestCheckResourceAttr("elasticstack_fleet_agent_policy.test_policy", "skip_destroy", "false"),
169+
resource.TestCheckResourceAttr("elasticstack_fleet_agent_policy.test_policy", "unenrollment_timeout", "300s"),
170+
),
171+
},
172+
{
173+
SkipFunc: versionutils.CheckIfVersionIsUnsupported(agent_policy.MinVersionUnenrollmentTimeout),
174+
Config: testAccResourceAgentPolicyUpdateWithTimeouts(policyName),
175+
Check: resource.ComposeTestCheckFunc(
176+
resource.TestCheckResourceAttr("elasticstack_fleet_agent_policy.test_policy", "name", fmt.Sprintf("Updated Policy %s", policyName)),
177+
resource.TestCheckResourceAttr("elasticstack_fleet_agent_policy.test_policy", "namespace", "default"),
178+
resource.TestCheckResourceAttr("elasticstack_fleet_agent_policy.test_policy", "description", "Test Agent Policy with Both Timeouts"),
179+
resource.TestCheckResourceAttr("elasticstack_fleet_agent_policy.test_policy", "monitor_logs", "false"),
180+
resource.TestCheckResourceAttr("elasticstack_fleet_agent_policy.test_policy", "monitor_metrics", "true"),
181+
resource.TestCheckResourceAttr("elasticstack_fleet_agent_policy.test_policy", "skip_destroy", "false"),
182+
resource.TestCheckResourceAttr("elasticstack_fleet_agent_policy.test_policy", "inactivity_timeout", "120s"),
183+
resource.TestCheckResourceAttr("elasticstack_fleet_agent_policy.test_policy", "unenrollment_timeout", "900s"),
184+
),
185+
},
159186
{
160187
SkipFunc: versionutils.CheckIfVersionIsUnsupported(agent_policy.MinVersionGlobalDataTags),
161188
Config: testAccResourceAgentPolicyCreateWithGlobalDataTags(policyNameGlobalDataTags, false),
@@ -332,6 +359,30 @@ data "elasticstack_fleet_enrollment_tokens" "test_policy" {
332359
`, fmt.Sprintf("Policy %s", id))
333360
}
334361

362+
func testAccResourceAgentPolicyCreateWithUnenrollmentTimeout(id string) string {
363+
return fmt.Sprintf(`
364+
provider "elasticstack" {
365+
elasticsearch {}
366+
kibana {}
367+
}
368+
369+
resource "elasticstack_fleet_agent_policy" "test_policy" {
370+
name = "%s"
371+
namespace = "default"
372+
description = "Test Agent Policy with Unenrollment Timeout"
373+
monitor_logs = true
374+
monitor_metrics = false
375+
skip_destroy = false
376+
unenrollment_timeout = "300s"
377+
}
378+
379+
data "elasticstack_fleet_enrollment_tokens" "test_policy" {
380+
policy_id = elasticstack_fleet_agent_policy.test_policy.policy_id
381+
}
382+
383+
`, fmt.Sprintf("Policy %s", id))
384+
}
385+
335386
func testAccResourceAgentPolicyCreateWithBadGlobalDataTags(id string, skipDestroy bool) string {
336387
return fmt.Sprintf(`
337388
provider "elasticstack" {
@@ -509,3 +560,28 @@ func checkResourceAgentPolicySkipDestroy(s *terraform.State) error {
509560
}
510561
return nil
511562
}
563+
564+
func testAccResourceAgentPolicyUpdateWithTimeouts(id string) string {
565+
return fmt.Sprintf(`
566+
provider "elasticstack" {
567+
elasticsearch {}
568+
kibana {}
569+
}
570+
571+
resource "elasticstack_fleet_agent_policy" "test_policy" {
572+
name = "%s"
573+
namespace = "default"
574+
description = "Test Agent Policy with Both Timeouts"
575+
monitor_logs = false
576+
monitor_metrics = true
577+
skip_destroy = false
578+
inactivity_timeout = "120s"
579+
unenrollment_timeout = "900s"
580+
}
581+
582+
data "elasticstack_fleet_enrollment_tokens" "test_policy" {
583+
policy_id = elasticstack_fleet_agent_policy.test_policy.policy_id
584+
}
585+
586+
`, fmt.Sprintf("Updated Policy %s", id))
587+
}

internal/fleet/agent_policy/schema.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,12 @@ func getSchema() schema.Schema {
104104
Optional: true,
105105
CustomType: customtypes.DurationType{},
106106
},
107+
"unenrollment_timeout": schema.StringAttribute{
108+
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').",
109+
Computed: true,
110+
Optional: true,
111+
CustomType: customtypes.DurationType{},
112+
},
107113
"global_data_tags": schema.MapNestedAttribute{
108114
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}",
109115
NestedObject: schema.NestedAttributeObject{

internal/fleet/agent_policy/version_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,27 @@ func TestMinVersionInactivityTimeout(t *testing.T) {
2929
}
3030
}
3131

32+
func TestMinVersionUnenrollmentTimeout(t *testing.T) {
33+
// Test that the MinVersionUnenrollmentTimeout constant is set correctly
34+
expected := "8.15.0"
35+
actual := MinVersionUnenrollmentTimeout.String()
36+
if actual != expected {
37+
t.Errorf("Expected MinVersionUnenrollmentTimeout to be '%s', got '%s'", expected, actual)
38+
}
39+
40+
// Test version comparison - should be greater than 8.14.0
41+
olderVersion := version.Must(version.NewVersion("8.14.0"))
42+
if MinVersionUnenrollmentTimeout.LessThan(olderVersion) {
43+
t.Errorf("MinVersionUnenrollmentTimeout (%s) should be greater than %s", MinVersionUnenrollmentTimeout.String(), olderVersion.String())
44+
}
45+
46+
// Test version comparison - should be less than 8.16.0
47+
newerVersion := version.Must(version.NewVersion("8.16.0"))
48+
if MinVersionUnenrollmentTimeout.GreaterThan(newerVersion) {
49+
t.Errorf("MinVersionUnenrollmentTimeout (%s) should be less than %s", MinVersionUnenrollmentTimeout.String(), newerVersion.String())
50+
}
51+
}
52+
3253
func TestInactivityTimeoutVersionValidation(t *testing.T) {
3354
ctx := context.Background()
3455

@@ -104,3 +125,79 @@ func TestInactivityTimeoutVersionValidation(t *testing.T) {
104125
t.Errorf("Did not expect error when inactivity_timeout is not set in update: %v", diags)
105126
}
106127
}
128+
129+
func TestUnenrollmentTimeoutVersionValidation(t *testing.T) {
130+
ctx := context.Background()
131+
132+
// Test case where unenrollment_timeout is not supported (older version)
133+
model := &agentPolicyModel{
134+
Name: types.StringValue("test"),
135+
Namespace: types.StringValue("default"),
136+
UnenrollmentTimeout: customtypes.NewDurationValue("5m"),
137+
}
138+
139+
// Create features with unenrollment timeout NOT supported
140+
feat := features{
141+
SupportsUnenrollmentTimeout: false,
142+
}
143+
144+
// Test toAPICreateModel - should return error when unenrollment_timeout is used but not supported
145+
_, diags := model.toAPICreateModel(ctx, feat)
146+
if !diags.HasError() {
147+
t.Error("Expected error when using unenrollment_timeout on unsupported version, but got none")
148+
}
149+
150+
// Check that the error message contains the expected text
151+
found := false
152+
for _, diag := range diags {
153+
if diag.Summary() == "Unsupported Elasticsearch version" {
154+
found = true
155+
break
156+
}
157+
}
158+
if !found {
159+
t.Error("Expected 'Unsupported Elasticsearch version' error, but didn't find it")
160+
}
161+
162+
// Test toAPIUpdateModel - should return error when unenrollment_timeout is used but not supported
163+
_, diags = model.toAPIUpdateModel(ctx, feat)
164+
if !diags.HasError() {
165+
t.Error("Expected error when using unenrollment_timeout on unsupported version in update, but got none")
166+
}
167+
168+
// Test case where unenrollment_timeout IS supported (newer version)
169+
featSupported := features{
170+
SupportsUnenrollmentTimeout: true,
171+
}
172+
173+
// Test toAPICreateModel - should NOT return error when unenrollment_timeout is supported
174+
_, diags = model.toAPICreateModel(ctx, featSupported)
175+
if diags.HasError() {
176+
t.Errorf("Did not expect error when using unenrollment_timeout on supported version: %v", diags)
177+
}
178+
179+
// Test toAPIUpdateModel - should NOT return error when unenrollment_timeout is supported
180+
_, diags = model.toAPIUpdateModel(ctx, featSupported)
181+
if diags.HasError() {
182+
t.Errorf("Did not expect error when using unenrollment_timeout on supported version in update: %v", diags)
183+
}
184+
185+
// Test case where unenrollment_timeout is not set (should not cause validation errors)
186+
modelWithoutTimeout := &agentPolicyModel{
187+
Name: types.StringValue("test"),
188+
Namespace: types.StringValue("default"),
189+
// UnenrollmentTimeout is not set (null/unknown)
190+
}
191+
192+
// Test toAPICreateModel - should NOT return error when unenrollment_timeout is not set, even on unsupported version
193+
_, diags = modelWithoutTimeout.toAPICreateModel(ctx, feat)
194+
if diags.HasError() {
195+
t.Errorf("Did not expect error when unenrollment_timeout is not set: %v", diags)
196+
}
197+
198+
// Test toAPIUpdateModel - should NOT return error when unenrollment_timeout is not set, even on unsupported version
199+
_, diags = modelWithoutTimeout.toAPIUpdateModel(ctx, feat)
200+
if diags.HasError() {
201+
t.Errorf("Did not expect error when unenrollment_timeout is not set in update: %v", diags)
202+
}
203+
}

0 commit comments

Comments
 (0)