Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/resources/fleet_agent_policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
87 changes: 67 additions & 20 deletions internal/fleet/agent_policy/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@
)

type features struct {
SupportsGlobalDataTags bool
SupportsSupportsAgentless bool
SupportsInactivityTimeout bool
SupportsGlobalDataTags bool
SupportsSupportsAgentless bool
SupportsInactivityTimeout bool
SupportsUnenrollmentTimeout bool
}

type globalDataTagsItemModel struct {
Expand All @@ -28,22 +29,23 @@
}

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 {
Expand Down Expand Up @@ -79,11 +81,20 @@
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)
seconds := int64(*data.InactivityTimeout)

Check failure on line 84 in internal/fleet/agent_policy/models.go

View workflow job for this annotation

GitHub Actions / copilot

invalid operation: int64(*data.InactivityTimeout) * time.Second (mismatched types int64 and time.Duration)
d := time.Duration(seconds) * time.Second
Comment on lines +84 to +85
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code is pretty confusing, where the duration is created incorrectly (using seconds, rather than nanoseconds, and then corrected. Fix up this case, and the UnenrollTimeout case below.

Suggested change
seconds := int64(*data.InactivityTimeout)
d := time.Duration(seconds) * time.Second
seconds := int64(*data.InactivityTimeout) * time.Second
d := time.Duration(seconds).Truncate(time.Second)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed the confusing duration conversion logic for both InactivityTimeout and UnenrollmentTimeout cases. The code now uses a cleaner approach with proper truncation for precision consistency. Changes in commit 6c44b35.

model.InactivityTimeout = customtypes.NewDurationValue(d.String())
} else {
model.InactivityTimeout = customtypes.NewDurationNull()
}
if data.UnenrollTimeout != nil {
// Convert seconds to duration string
seconds := int64(*data.UnenrollTimeout)

Check failure on line 92 in internal/fleet/agent_policy/models.go

View workflow job for this annotation

GitHub Actions / copilot

invalid operation: int64(*data.UnenrollTimeout) * time.Second (mismatched types int64 and time.Duration) (typecheck)
d := time.Duration(seconds) * time.Second
model.UnenrollmentTimeout = customtypes.NewDurationValue(d.String())
} else {
model.UnenrollmentTimeout = customtypes.NewDurationNull()
}
if utils.Deref(data.GlobalDataTags) != nil {
diags := diag.Diagnostics{}
var map0 = make(map[string]globalDataTagsItemModel)
Expand Down Expand Up @@ -216,6 +227,24 @@
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
Expand Down Expand Up @@ -276,6 +305,24 @@
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
Expand Down
19 changes: 13 additions & 6 deletions internal/fleet/agent_policy/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
76 changes: 76 additions & 0 deletions internal/fleet/agent_policy/resource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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" {
Expand Down Expand Up @@ -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))
}
6 changes: 6 additions & 0 deletions internal/fleet/agent_policy/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
97 changes: 97 additions & 0 deletions internal/fleet/agent_policy/version_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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)
}
}
Loading