diff --git a/CHANGELOG.md b/CHANGELOG.md index fe238fb3c..e54c496af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## [Unreleased] +- Add `required_versions` to `elasticstack_fleet_agent_policy` ([#1436](https://github.com/elastic/terraform-provider-elasticstack/pull/1436)) - Fix `elasticstack_elasticsearch_snapshot_lifecycle` metadata type conversion causing terraform apply to fail ([#1409](https://github.com/elastic/terraform-provider-elasticstack/issues/1409)) - Add new `elasticstack_elasticsearch_ml_anomaly_detection_job` resource ([#1329](https://github.com/elastic/terraform-provider-elasticstack/pull/1329)) - Add new `elasticstack_elasticsearch_ml_datafeed` resource ([1340](https://github.com/elastic/terraform-provider-elasticstack/pull/1340)) diff --git a/docs/resources/fleet_agent_policy.md b/docs/resources/fleet_agent_policy.md index 149ec5f8d..cf82b6b1b 100644 --- a/docs/resources/fleet_agent_policy.md +++ b/docs/resources/fleet_agent_policy.md @@ -57,6 +57,7 @@ resource "elasticstack_fleet_agent_policy" "test_policy" { - `monitor_metrics` (Boolean) Enable collection of agent metrics. - `monitoring_output_id` (String) The identifier for monitoring output. - `policy_id` (String) Unique identifier of the agent policy. +- `required_versions` (Map of Number) Map of agent versions to target percentages for automatic upgrade. The key is the target version and the value is the percentage of agents to upgrade to that version. - `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. - `space_ids` (Set of String) The Kibana space IDs that this agent policy should be available in. When not specified, defaults to ["default"]. Note: The order of space IDs does not matter as this is a set. - `supports_agentless` (Boolean) Set to true to enable agentless data collection. diff --git a/internal/fleet/agent_policy/acc_test.go b/internal/fleet/agent_policy/acc_test.go index 7d7f54213..d9d918243 100644 --- a/internal/fleet/agent_policy/acc_test.go +++ b/internal/fleet/agent_policy/acc_test.go @@ -22,6 +22,7 @@ import ( ) var minVersionAgentPolicy = version.Must(version.NewVersion("8.6.0")) +var minVersionRequiredVersions = version.Must(version.NewVersion("9.1.0")) //go:embed testdata/TestAccResourceAgentPolicyFromSDK/main.tf var sdkCreateTestConfig string @@ -524,3 +525,85 @@ func checkResourceAgentPolicySkipDestroy(s *terraform.State) error { } return nil } + +func TestAccResourceAgentPolicyWithRequiredVersions(t *testing.T) { + policyName := sdkacctest.RandStringFromCharSet(22, sdkacctest.CharSetAlphaNum) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + CheckDestroy: checkResourceAgentPolicyDestroy, + Steps: []resource.TestStep{ + { + ProtoV6ProviderFactories: acctest.Providers, + SkipFunc: versionutils.CheckIfVersionIsUnsupported(minVersionRequiredVersions), + ConfigDirectory: acctest.NamedTestCaseDirectory("create"), + ConfigVariables: config.Variables{ + "policy_name": config.StringVariable(fmt.Sprintf("Policy %s", 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", "required_versions.%", "1"), + resource.TestCheckResourceAttr("elasticstack_fleet_agent_policy.test_policy", "required_versions.8.15.0", "100"), + ), + }, + { + ProtoV6ProviderFactories: acctest.Providers, + SkipFunc: versionutils.CheckIfVersionIsUnsupported(minVersionRequiredVersions), + ConfigDirectory: acctest.NamedTestCaseDirectory("update_percentage"), + ConfigVariables: config.Variables{ + "policy_name": config.StringVariable(fmt.Sprintf("Policy %s", 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", "required_versions.%", "1"), + resource.TestCheckResourceAttr("elasticstack_fleet_agent_policy.test_policy", "required_versions.8.15.0", "50"), + ), + }, + { + ProtoV6ProviderFactories: acctest.Providers, + SkipFunc: versionutils.CheckIfVersionIsUnsupported(minVersionRequiredVersions), + ConfigDirectory: acctest.NamedTestCaseDirectory("add_version"), + ConfigVariables: config.Variables{ + "policy_name": config.StringVariable(fmt.Sprintf("Policy %s", 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", "required_versions.%", "2"), + resource.TestCheckResourceAttr("elasticstack_fleet_agent_policy.test_policy", "required_versions.8.15.0", "50"), + resource.TestCheckResourceAttr("elasticstack_fleet_agent_policy.test_policy", "required_versions.8.16.0", "50"), + ), + }, + { + ProtoV6ProviderFactories: acctest.Providers, + SkipFunc: versionutils.CheckIfVersionIsUnsupported(minVersionRequiredVersions), + ConfigDirectory: acctest.NamedTestCaseDirectory("unset_versions"), + ConfigVariables: config.Variables{ + "policy_name": config.StringVariable(fmt.Sprintf("Policy %s", 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", "required_versions.%", "2"), + resource.TestCheckResourceAttr("elasticstack_fleet_agent_policy.test_policy", "required_versions.8.15.0", "50"), + resource.TestCheckResourceAttr("elasticstack_fleet_agent_policy.test_policy", "required_versions.8.16.0", "50"), + ), + }, + { + ProtoV6ProviderFactories: acctest.Providers, + SkipFunc: versionutils.CheckIfVersionIsUnsupported(minVersionRequiredVersions), + ConfigDirectory: acctest.NamedTestCaseDirectory("remove_versions"), + ConfigVariables: config.Variables{ + "policy_name": config.StringVariable(fmt.Sprintf("Policy %s", 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", "required_versions.%", "0"), + ), + }, + }, + }) +} diff --git a/internal/fleet/agent_policy/models.go b/internal/fleet/agent_policy/models.go index 24c854e55..80614df3b 100644 --- a/internal/fleet/agent_policy/models.go +++ b/internal/fleet/agent_policy/models.go @@ -3,6 +3,7 @@ package agent_policy import ( "context" "fmt" + "math" "slices" "time" @@ -22,6 +23,7 @@ type features struct { SupportsInactivityTimeout bool SupportsUnenrollmentTimeout bool SupportsSpaceIds bool + SupportsRequiredVersions bool } type globalDataTagsItemModel struct { @@ -48,6 +50,7 @@ type agentPolicyModel struct { UnenrollmentTimeout customtypes.Duration `tfsdk:"unenrollment_timeout"` GlobalDataTags types.Map `tfsdk:"global_data_tags"` //> globalDataTagsModel SpaceIds types.Set `tfsdk:"space_ids"` + RequiredVersions types.Map `tfsdk:"required_versions"` } func (model *agentPolicyModel) populateFromAPI(ctx context.Context, data *kbapi.AgentPolicy) diag.Diagnostics { @@ -134,6 +137,25 @@ func (model *agentPolicyModel) populateFromAPI(ctx context.Context, data *kbapi. model.SpaceIds = types.SetNull(types.StringType) } + // Handle required_versions + if data.RequiredVersions != nil { + versionMap := make(map[string]attr.Value) + + for _, rv := range *data.RequiredVersions { + // Round the float32 percentage to nearest integer since we use Int32 in the schema + percentage := int32(math.Round(float64(rv.Percentage))) + versionMap[rv.Version] = types.Int32Value(percentage) + } + + reqVersions, d := types.MapValue(types.Int32Type, versionMap) + if d.HasError() { + return d + } + model.RequiredVersions = reqVersions + } else { + model.RequiredVersions = types.MapNull(types.Int32Type) + } + return nil } @@ -186,6 +208,72 @@ func (model *agentPolicyModel) convertGlobalDataTags(ctx context.Context, feat f return &itemsList, diags } +// convertRequiredVersions converts the required versions from terraform model to API model +func (model *agentPolicyModel) convertRequiredVersions(ctx context.Context, feat features) (*[]struct { + Percentage float32 `json:"percentage"` + Version string `json:"version"` +}, diag.Diagnostics) { + var diags diag.Diagnostics + + if model.RequiredVersions.IsNull() || model.RequiredVersions.IsUnknown() { + return nil, diags + } + + // Check if required_versions is supported + if !feat.SupportsRequiredVersions { + return nil, diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("required_versions"), + "Unsupported Elasticsearch version", + fmt.Sprintf("Required versions (automatic agent upgrades) are only supported in Elastic Stack %s and above", MinVersionRequiredVersions), + ), + } + } + + elements := model.RequiredVersions.Elements() + + // If the map is empty (required_versions = {}), return an empty array to clear upgrades + if len(elements) == 0 { + emptyArray := make([]struct { + Percentage float32 `json:"percentage"` + Version string `json:"version"` + }, 0) + return &emptyArray, diags + } + + result := make([]struct { + Percentage float32 `json:"percentage"` + Version string `json:"version"` + }, 0, len(elements)) + + for version, percentageVal := range elements { + percentageInt32, ok := percentageVal.(types.Int32) + if !ok { + diags.AddError("required_versions conversion error", fmt.Sprintf("Expected Int32 value, got %T", percentageVal)) + continue + } + + if percentageInt32.IsNull() || percentageInt32.IsUnknown() { + diags.AddError("required_versions validation error", "percentage cannot be null or unknown") + continue + } + + result = append(result, struct { + Percentage float32 `json:"percentage"` + Version string `json:"version"` + }{ + Percentage: float32(percentageInt32.ValueInt32()), + Version: version, + }) + } + + if diags.HasError() { + return nil, diags + } + + return &result, diags +} + func (model *agentPolicyModel) toAPICreateModel(ctx context.Context, feat features) (kbapi.PostFleetAgentPoliciesJSONRequestBody, diag.Diagnostics) { monitoring := make([]kbapi.PostFleetAgentPoliciesJSONBodyMonitoringEnabled, 0, 2) @@ -282,6 +370,13 @@ func (model *agentPolicyModel) toAPICreateModel(ctx context.Context, feat featur body.SpaceIds = &spaceIds } + // Handle required_versions + requiredVersions, d := model.convertRequiredVersions(ctx, feat) + if d.HasError() { + return kbapi.PostFleetAgentPoliciesJSONRequestBody{}, d + } + body.RequiredVersions = requiredVersions + return body, nil } @@ -379,5 +474,12 @@ func (model *agentPolicyModel) toAPIUpdateModel(ctx context.Context, feat featur body.SpaceIds = &spaceIds } + // Handle required_versions + requiredVersions, d := model.convertRequiredVersions(ctx, feat) + if d.HasError() { + return kbapi.PutFleetAgentPoliciesAgentpolicyidJSONRequestBody{}, d + } + body.RequiredVersions = requiredVersions + return body, nil } diff --git a/internal/fleet/agent_policy/resource.go b/internal/fleet/agent_policy/resource.go index 5af42e7af..c0028d162 100644 --- a/internal/fleet/agent_policy/resource.go +++ b/internal/fleet/agent_policy/resource.go @@ -24,6 +24,7 @@ var ( MinVersionInactivityTimeout = version.Must(version.NewVersion("8.7.0")) MinVersionUnenrollmentTimeout = version.Must(version.NewVersion("8.15.0")) MinVersionSpaceIds = version.Must(version.NewVersion("9.1.0")) + MinVersionRequiredVersions = version.Must(version.NewVersion("9.1.0")) ) // NewResource is a helper function to simplify the provider implementation. @@ -75,11 +76,17 @@ func (r *agentPolicyResource) buildFeatures(ctx context.Context) (features, diag return features{}, diagutil.FrameworkDiagsFromSDK(diags) } + supportsRequiredVersions, diags := r.client.EnforceMinVersion(ctx, MinVersionRequiredVersions) + if diags.HasError() { + return features{}, diagutil.FrameworkDiagsFromSDK(diags) + } + return features{ SupportsGlobalDataTags: supportsGDT, SupportsSupportsAgentless: supportsSupportsAgentless, SupportsInactivityTimeout: supportsInactivityTimeout, SupportsUnenrollmentTimeout: supportsUnenrollmentTimeout, SupportsSpaceIds: supportsSpaceIds, + SupportsRequiredVersions: supportsRequiredVersions, }, nil } diff --git a/internal/fleet/agent_policy/schema.go b/internal/fleet/agent_policy/schema.go index e2291b0ec..76014c871 100644 --- a/internal/fleet/agent_policy/schema.go +++ b/internal/fleet/agent_policy/schema.go @@ -13,6 +13,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/mapdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/mapplanmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/schema/validator" @@ -145,6 +146,15 @@ func getSchema() schema.Schema { Optional: true, Computed: true, }, + "required_versions": schema.MapAttribute{ + Description: "Map of agent versions to target percentages for automatic upgrade. The key is the target version and the value is the percentage of agents to upgrade to that version.", + ElementType: types.Int32Type, + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Map{ + mapplanmodifier.UseStateForUnknown(), + }, + }, }} } func getGlobalDataTagsAttrTypes() attr.Type { diff --git a/internal/fleet/agent_policy/testdata/TestAccResourceAgentPolicyWithRequiredVersions/add_version/main.tf b/internal/fleet/agent_policy/testdata/TestAccResourceAgentPolicyWithRequiredVersions/add_version/main.tf new file mode 100644 index 000000000..8573493c8 --- /dev/null +++ b/internal/fleet/agent_policy/testdata/TestAccResourceAgentPolicyWithRequiredVersions/add_version/main.tf @@ -0,0 +1,26 @@ +variable "policy_name" { + type = string + description = "Name for the agent policy" +} + +provider "elasticstack" { + elasticsearch {} + kibana {} +} + +resource "elasticstack_fleet_agent_policy" "test_policy" { + name = var.policy_name + namespace = "default" + description = "Test Agent Policy with Multiple Required Versions" + monitor_logs = true + monitor_metrics = false + skip_destroy = false + required_versions = { + "8.15.0" = 50 + "8.16.0" = 50 + } +} + +data "elasticstack_fleet_enrollment_tokens" "test_policy" { + policy_id = elasticstack_fleet_agent_policy.test_policy.policy_id +} diff --git a/internal/fleet/agent_policy/testdata/TestAccResourceAgentPolicyWithRequiredVersions/create/main.tf b/internal/fleet/agent_policy/testdata/TestAccResourceAgentPolicyWithRequiredVersions/create/main.tf new file mode 100644 index 000000000..88b58460a --- /dev/null +++ b/internal/fleet/agent_policy/testdata/TestAccResourceAgentPolicyWithRequiredVersions/create/main.tf @@ -0,0 +1,25 @@ +variable "policy_name" { + type = string + description = "Name for the agent policy" +} + +provider "elasticstack" { + elasticsearch {} + kibana {} +} + +resource "elasticstack_fleet_agent_policy" "test_policy" { + name = var.policy_name + namespace = "default" + description = "Test Agent Policy with Required Versions" + monitor_logs = true + monitor_metrics = false + skip_destroy = false + required_versions = { + "8.15.0" = 100 + } +} + +data "elasticstack_fleet_enrollment_tokens" "test_policy" { + policy_id = elasticstack_fleet_agent_policy.test_policy.policy_id +} diff --git a/internal/fleet/agent_policy/testdata/TestAccResourceAgentPolicyWithRequiredVersions/remove_versions/main.tf b/internal/fleet/agent_policy/testdata/TestAccResourceAgentPolicyWithRequiredVersions/remove_versions/main.tf new file mode 100644 index 000000000..792a938aa --- /dev/null +++ b/internal/fleet/agent_policy/testdata/TestAccResourceAgentPolicyWithRequiredVersions/remove_versions/main.tf @@ -0,0 +1,23 @@ +variable "policy_name" { + type = string + description = "Name for the agent policy" +} + +provider "elasticstack" { + elasticsearch {} + kibana {} +} + +resource "elasticstack_fleet_agent_policy" "test_policy" { + name = var.policy_name + namespace = "default" + description = "Test Agent Policy without Required Versions" + monitor_logs = true + monitor_metrics = false + skip_destroy = false + required_versions = {} +} + +data "elasticstack_fleet_enrollment_tokens" "test_policy" { + policy_id = elasticstack_fleet_agent_policy.test_policy.policy_id +} diff --git a/internal/fleet/agent_policy/testdata/TestAccResourceAgentPolicyWithRequiredVersions/unset_versions/main.tf b/internal/fleet/agent_policy/testdata/TestAccResourceAgentPolicyWithRequiredVersions/unset_versions/main.tf new file mode 100644 index 000000000..2160d3333 --- /dev/null +++ b/internal/fleet/agent_policy/testdata/TestAccResourceAgentPolicyWithRequiredVersions/unset_versions/main.tf @@ -0,0 +1,22 @@ +variable "policy_name" { + type = string + description = "Name for the agent policy" +} + +provider "elasticstack" { + elasticsearch {} + kibana {} +} + +resource "elasticstack_fleet_agent_policy" "test_policy" { + name = var.policy_name + namespace = "default" + description = "Test Agent Policy without Required Versions" + monitor_logs = true + monitor_metrics = false + skip_destroy = false +} + +data "elasticstack_fleet_enrollment_tokens" "test_policy" { + policy_id = elasticstack_fleet_agent_policy.test_policy.policy_id +} diff --git a/internal/fleet/agent_policy/testdata/TestAccResourceAgentPolicyWithRequiredVersions/update_percentage/main.tf b/internal/fleet/agent_policy/testdata/TestAccResourceAgentPolicyWithRequiredVersions/update_percentage/main.tf new file mode 100644 index 000000000..fbe53d067 --- /dev/null +++ b/internal/fleet/agent_policy/testdata/TestAccResourceAgentPolicyWithRequiredVersions/update_percentage/main.tf @@ -0,0 +1,25 @@ +variable "policy_name" { + type = string + description = "Name for the agent policy" +} + +provider "elasticstack" { + elasticsearch {} + kibana {} +} + +resource "elasticstack_fleet_agent_policy" "test_policy" { + name = var.policy_name + namespace = "default" + description = "Test Agent Policy with Required Versions - Updated Percentage" + monitor_logs = true + monitor_metrics = false + skip_destroy = false + required_versions = { + "8.15.0" = 50 + } +} + +data "elasticstack_fleet_enrollment_tokens" "test_policy" { + policy_id = elasticstack_fleet_agent_policy.test_policy.policy_id +}