diff --git a/CHANGELOG.md b/CHANGELOG.md index 07d68cb2f..3fee09cd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ alias = [ ### Changes +- Add `host_name_format` to `elasticstack_fleet_agent_policy` to configure host name format (hostname or FQDN) ([#1312](https://github.com/elastic/terraform-provider-elasticstack/pull/1312)) - Create `elasticstack_kibana_prebuilt_rule` resource ([#1296](https://github.com/elastic/terraform-provider-elasticstack/pull/1296)) - Add `required_versions` to `elasticstack_fleet_agent_policy` ([#1436](https://github.com/elastic/terraform-provider-elasticstack/pull/1436)) - Migrate `elasticstack_elasticsearch_security_role` resource to Terraform Plugin Framework ([#1330](https://github.com/elastic/terraform-provider-elasticstack/pull/1330)) diff --git a/Makefile b/Makefile index 574a5b94c..1624a57ae 100644 --- a/Makefile +++ b/Makefile @@ -28,6 +28,8 @@ KIBANA_API_KEY_NAME ?= kibana-api-key FLEET_NAME ?= terraform-elasticstack-fleet FLEET_ENDPOINT ?= https://$(FLEET_NAME):8220 +RERUN_FAILS ?= 3 + export GOBIN = $(shell pwd)/bin @@ -53,7 +55,7 @@ testacc-vs-docker: .PHONY: testacc testacc: ## Run acceptance tests - TF_ACC=1 go tool gotestsum --format testname --rerun-fails=3 --packages="-v ./..." -- -count $(ACCTEST_COUNT) -parallel $(ACCTEST_PARALLELISM) $(TESTARGS) -timeout $(ACCTEST_TIMEOUT) + TF_ACC=1 go tool gotestsum --format testname --rerun-fails=$(RERUN_FAILS) --packages="-v ./..." -- -count $(ACCTEST_COUNT) -parallel $(ACCTEST_PARALLELISM) $(TESTARGS) -timeout $(ACCTEST_TIMEOUT) .PHONY: test test: ## Run unit tests diff --git a/docs/resources/fleet_agent_policy.md b/docs/resources/fleet_agent_policy.md index cf82b6b1b..c35705fa7 100644 --- a/docs/resources/fleet_agent_policy.md +++ b/docs/resources/fleet_agent_policy.md @@ -18,13 +18,14 @@ provider "elasticstack" { } resource "elasticstack_fleet_agent_policy" "test_policy" { - name = "Test Policy" - namespace = "default" - description = "Test Agent Policy" - sys_monitoring = true - monitor_logs = true - monitor_metrics = true - space_ids = ["default"] + name = "Test Policy" + namespace = "default" + description = "Test Agent Policy" + sys_monitoring = true + monitor_logs = true + monitor_metrics = true + space_ids = ["default"] + host_name_format = "hostname" # or "fqdn" for fully qualified domain names global_data_tags = { first_tag = { @@ -52,6 +53,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)) +- `host_name_format` (String) Determines the format of the host.name field in events. Can be 'hostname' (short hostname, e.g., 'myhost') or 'fqdn' (fully qualified domain name, e.g., 'myhost.example.com'). Defaults to 'hostname'. - `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. diff --git a/examples/resources/elasticstack_fleet_agent_policy/resource.tf b/examples/resources/elasticstack_fleet_agent_policy/resource.tf index e66afbd84..a0835d691 100644 --- a/examples/resources/elasticstack_fleet_agent_policy/resource.tf +++ b/examples/resources/elasticstack_fleet_agent_policy/resource.tf @@ -3,13 +3,14 @@ provider "elasticstack" { } resource "elasticstack_fleet_agent_policy" "test_policy" { - name = "Test Policy" - namespace = "default" - description = "Test Agent Policy" - sys_monitoring = true - monitor_logs = true - monitor_metrics = true - space_ids = ["default"] + name = "Test Policy" + namespace = "default" + description = "Test Agent Policy" + sys_monitoring = true + monitor_logs = true + monitor_metrics = true + space_ids = ["default"] + host_name_format = "hostname" # or "fqdn" for fully qualified domain names global_data_tags = { first_tag = { diff --git a/internal/fleet/agent_policy/acc_test.go b/internal/fleet/agent_policy/acc_test.go index bf3367628..e35b75030 100644 --- a/internal/fleet/agent_policy/acc_test.go +++ b/internal/fleet/agent_policy/acc_test.go @@ -525,6 +525,62 @@ func checkResourceAgentPolicySkipDestroy(s *terraform.State) error { return nil } +func TestAccResourceAgentPolicyWithHostNameFormat(t *testing.T) { + policyName := sdkacctest.RandStringFromCharSet(22, sdkacctest.CharSetAlphaNum) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + CheckDestroy: checkResourceAgentPolicyDestroy, + Steps: []resource.TestStep{ + { + // Step 1: Create with host_name_format = "fqdn" + ProtoV6ProviderFactories: acctest.Providers, + SkipFunc: versionutils.CheckIfVersionIsUnsupported(agent_policy.MinVersionAgentFeatures), + ConfigDirectory: acctest.NamedTestCaseDirectory("create_with_fqdn"), + 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", "description", "Test Agent Policy with FQDN host name format"), + resource.TestCheckResourceAttr("elasticstack_fleet_agent_policy.test_policy", "host_name_format", "fqdn"), + ), + }, + { + // Step 2: Remove host_name_format from config - should use default "hostname" + ProtoV6ProviderFactories: acctest.Providers, + SkipFunc: versionutils.CheckIfVersionIsUnsupported(agent_policy.MinVersionAgentFeatures), + ConfigDirectory: acctest.NamedTestCaseDirectory("remove_host_name_format"), + 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", "description", "Test Agent Policy without host_name_format"), + resource.TestCheckResourceAttr("elasticstack_fleet_agent_policy.test_policy", "host_name_format", "hostname"), + ), + }, + { + // Step 3: Explicitly set host_name_format = "hostname" + ProtoV6ProviderFactories: acctest.Providers, + SkipFunc: versionutils.CheckIfVersionIsUnsupported(agent_policy.MinVersionAgentFeatures), + ConfigDirectory: acctest.NamedTestCaseDirectory("update_to_hostname"), + 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", "description", "Test Agent Policy with hostname format"), + resource.TestCheckResourceAttr("elasticstack_fleet_agent_policy.test_policy", "host_name_format", "hostname"), + ), + }, + }, + }) +} + func TestAccResourceAgentPolicyWithRequiredVersions(t *testing.T) { policyName := sdkacctest.RandStringFromCharSet(22, sdkacctest.CharSetAlphaNum) diff --git a/internal/fleet/agent_policy/models.go b/internal/fleet/agent_policy/models.go index 829f67d69..fa591e453 100644 --- a/internal/fleet/agent_policy/models.go +++ b/internal/fleet/agent_policy/models.go @@ -17,6 +17,21 @@ import ( "github.com/hashicorp/terraform-plugin-framework/types" ) +const ( + // HostNameFormatHostname represents the short hostname format (e.g., "myhost") + HostNameFormatHostname = "hostname" + // HostNameFormatFQDN represents the fully qualified domain name format (e.g., "myhost.example.com") + HostNameFormatFQDN = "fqdn" + // agentFeatureFQDN is the name of the agent feature that enables FQDN host name format + agentFeatureFQDN = "fqdn" +) + +// apiAgentFeature is the type expected by the generated API for agent features +type apiAgentFeature = struct { + Enabled bool `json:"enabled"` + Name string `json:"name"` +} + type features struct { SupportsGlobalDataTags bool SupportsSupportsAgentless bool @@ -24,6 +39,7 @@ type features struct { SupportsUnenrollmentTimeout bool SupportsSpaceIds bool SupportsRequiredVersions bool + SupportsAgentFeatures bool } type globalDataTagsItemModel struct { @@ -45,6 +61,7 @@ type agentPolicyModel struct { MonitorMetrics types.Bool `tfsdk:"monitor_metrics"` SysMonitoring types.Bool `tfsdk:"sys_monitoring"` SkipDestroy types.Bool `tfsdk:"skip_destroy"` + HostNameFormat types.String `tfsdk:"host_name_format"` SupportsAgentless types.Bool `tfsdk:"supports_agentless"` InactivityTimeout customtypes.Duration `tfsdk:"inactivity_timeout"` UnenrollmentTimeout customtypes.Duration `tfsdk:"unenrollment_timeout"` @@ -84,6 +101,20 @@ 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) + + // Determine host_name_format from AgentFeatures + // If AgentFeatures contains {"enabled": true, "name": "fqdn"}, then host_name_format is "fqdn" + // Otherwise, it defaults to "hostname" + model.HostNameFormat = types.StringValue(HostNameFormatHostname) + if data.AgentFeatures != nil { + for _, feature := range *data.AgentFeatures { + if feature.Name == agentFeatureFQDN && feature.Enabled { + model.HostNameFormat = types.StringValue(HostNameFormatFQDN) + break + } + } + } + if data.InactivityTimeout != nil { // Convert seconds to duration string seconds := int64(*data.InactivityTimeout) @@ -377,10 +408,30 @@ func (model *agentPolicyModel) toAPICreateModel(ctx context.Context, feat featur } body.RequiredVersions = requiredVersions + // Handle host_name_format via AgentFeatures + if agentFeature := model.convertHostNameFormatToAgentFeature(); agentFeature != nil { + if !feat.SupportsAgentFeatures { + // Only error if user explicitly requests FQDN on unsupported version + // Default "hostname" is fine - just don't send agent_features + if agentFeature.Enabled { + return kbapi.PostFleetAgentPoliciesJSONRequestBody{}, diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("host_name_format"), + "Unsupported Elasticsearch version", + fmt.Sprintf("host_name_format (agent_features) is only supported in Elastic Stack %s and above", MinVersionAgentFeatures), + ), + } + } + // On unsupported version with default "hostname", don't send agent_features + } else { + body.AgentFeatures = &[]apiAgentFeature{*agentFeature} + } + } + return body, nil } -func (model *agentPolicyModel) toAPIUpdateModel(ctx context.Context, feat features) (kbapi.PutFleetAgentPoliciesAgentpolicyidJSONRequestBody, diag.Diagnostics) { +func (model *agentPolicyModel) toAPIUpdateModel(ctx context.Context, feat features, existingFeatures []apiAgentFeature) (kbapi.PutFleetAgentPoliciesAgentpolicyidJSONRequestBody, diag.Diagnostics) { monitoring := make([]kbapi.PutFleetAgentPoliciesAgentpolicyidJSONBodyMonitoringEnabled, 0, 2) if model.MonitorLogs.ValueBool() { monitoring = append(monitoring, kbapi.PutFleetAgentPoliciesAgentpolicyidJSONBodyMonitoringEnabledLogs) @@ -481,5 +532,76 @@ func (model *agentPolicyModel) toAPIUpdateModel(ctx context.Context, feat featur } body.RequiredVersions = requiredVersions + // Handle host_name_format via AgentFeatures, preserving other existing features + if agentFeature := model.convertHostNameFormatToAgentFeature(); agentFeature != nil { + if !feat.SupportsAgentFeatures { + // Only error if user explicitly requests FQDN on unsupported version + // Default "hostname" is fine - just don't send agent_features + if agentFeature.Enabled { + return kbapi.PutFleetAgentPoliciesAgentpolicyidJSONRequestBody{}, diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("host_name_format"), + "Unsupported Elasticsearch version", + fmt.Sprintf("host_name_format (agent_features) is only supported in Elastic Stack %s and above", MinVersionAgentFeatures), + ), + } + } + // On unsupported version with default "hostname", don't send agent_features + } else { + body.AgentFeatures = mergeAgentFeature(existingFeatures, agentFeature) + } + } else if feat.SupportsAgentFeatures && len(existingFeatures) > 0 { + // Preserve existing features even when host_name_format is not set + body.AgentFeatures = &existingFeatures + } + return body, nil } + +// convertHostNameFormatToAgentFeature converts the host_name_format field to a single AgentFeature. +// - When host_name_format is "fqdn": returns {"name": "fqdn", "enabled": true} +// - When host_name_format is "hostname": returns {"name": "fqdn", "enabled": false} to explicitly disable +// - When not set: returns nil (no change to existing features) +func (model *agentPolicyModel) convertHostNameFormatToAgentFeature() *apiAgentFeature { + // If host_name_format is not set or unknown, don't modify AgentFeatures + if model.HostNameFormat.IsNull() || model.HostNameFormat.IsUnknown() { + return nil + } + + // Explicitly set enabled based on the host_name_format value + // We need to send enabled: false when hostname is selected to override any existing fqdn setting + return &apiAgentFeature{ + Enabled: model.HostNameFormat.ValueString() == HostNameFormatFQDN, + Name: agentFeatureFQDN, + } +} + +// mergeAgentFeature merges a single feature into existing features, replacing any feature with the same name. +// If newFeature is nil, returns existing features unchanged (nil if existing is empty). +func mergeAgentFeature(existing []apiAgentFeature, newFeature *apiAgentFeature) *[]apiAgentFeature { + if newFeature == nil { + if len(existing) == 0 { + return nil + } + return &existing + } + + // Check if the feature already exists and replace it, otherwise append + result := make([]apiAgentFeature, 0, len(existing)+1) + found := false + + for _, f := range existing { + if f.Name == newFeature.Name { + result = append(result, *newFeature) + found = true + } else { + result = append(result, f) + } + } + + if !found { + result = append(result, *newFeature) + } + + return &result +} diff --git a/internal/fleet/agent_policy/models_test.go b/internal/fleet/agent_policy/models_test.go new file mode 100644 index 000000000..a49b30610 --- /dev/null +++ b/internal/fleet/agent_policy/models_test.go @@ -0,0 +1,145 @@ +package agent_policy + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stretchr/testify/assert" +) + +func TestMergeAgentFeature(t *testing.T) { + tests := []struct { + name string + existing []apiAgentFeature + newFeature *apiAgentFeature + want *[]apiAgentFeature + }{ + { + name: "nil new feature with empty existing returns nil", + existing: nil, + newFeature: nil, + want: nil, + }, + { + name: "nil new feature with empty slice returns nil", + existing: []apiAgentFeature{}, + newFeature: nil, + want: nil, + }, + { + name: "nil new feature preserves existing features", + existing: []apiAgentFeature{ + {Name: "feature1", Enabled: true}, + {Name: "feature2", Enabled: false}, + }, + newFeature: nil, + want: &[]apiAgentFeature{ + {Name: "feature1", Enabled: true}, + {Name: "feature2", Enabled: false}, + }, + }, + { + name: "new feature added to empty existing", + existing: nil, + newFeature: &apiAgentFeature{Name: "fqdn", Enabled: true}, + want: &[]apiAgentFeature{ + {Name: "fqdn", Enabled: true}, + }, + }, + { + name: "new feature added when not present", + existing: []apiAgentFeature{ + {Name: "other", Enabled: true}, + }, + newFeature: &apiAgentFeature{Name: "fqdn", Enabled: true}, + want: &[]apiAgentFeature{ + {Name: "other", Enabled: true}, + {Name: "fqdn", Enabled: true}, + }, + }, + { + name: "existing feature replaced", + existing: []apiAgentFeature{ + {Name: "fqdn", Enabled: false}, + {Name: "other", Enabled: true}, + }, + newFeature: &apiAgentFeature{Name: "fqdn", Enabled: true}, + want: &[]apiAgentFeature{ + {Name: "fqdn", Enabled: true}, + {Name: "other", Enabled: true}, + }, + }, + { + name: "feature disabled replaces enabled", + existing: []apiAgentFeature{ + {Name: "fqdn", Enabled: true}, + }, + newFeature: &apiAgentFeature{Name: "fqdn", Enabled: false}, + want: &[]apiAgentFeature{ + {Name: "fqdn", Enabled: false}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := mergeAgentFeature(tt.existing, tt.newFeature) + + if tt.want == nil { + assert.Nil(t, got) + return + } + + assert.NotNil(t, got) + assert.Equal(t, *tt.want, *got) + }) + } +} + +func TestConvertHostNameFormatToAgentFeature(t *testing.T) { + tests := []struct { + name string + hostNameFormat types.String + want *apiAgentFeature + }{ + { + name: "null host_name_format returns nil", + hostNameFormat: types.StringNull(), + want: nil, + }, + { + name: "unknown host_name_format returns nil", + hostNameFormat: types.StringUnknown(), + want: nil, + }, + { + name: "fqdn returns enabled feature", + hostNameFormat: types.StringValue(HostNameFormatFQDN), + want: &apiAgentFeature{Name: agentFeatureFQDN, Enabled: true}, + }, + { + name: "hostname returns disabled feature", + hostNameFormat: types.StringValue(HostNameFormatHostname), + want: &apiAgentFeature{Name: agentFeatureFQDN, Enabled: false}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + model := &agentPolicyModel{ + HostNameFormat: tt.hostNameFormat, + } + + got := model.convertHostNameFormatToAgentFeature() + + if tt.want == nil { + assert.Nil(t, got) + return + } + + assert.NotNil(t, got) + assert.Equal(t, tt.want.Name, got.Name) + assert.Equal(t, tt.want.Enabled, got.Enabled) + }) + } +} diff --git a/internal/fleet/agent_policy/resource.go b/internal/fleet/agent_policy/resource.go index c0028d162..15aaed2f9 100644 --- a/internal/fleet/agent_policy/resource.go +++ b/internal/fleet/agent_policy/resource.go @@ -25,6 +25,7 @@ var ( MinVersionUnenrollmentTimeout = version.Must(version.NewVersion("8.15.0")) MinVersionSpaceIds = version.Must(version.NewVersion("9.1.0")) MinVersionRequiredVersions = version.Must(version.NewVersion("9.1.0")) + MinVersionAgentFeatures = version.Must(version.NewVersion("8.7.0")) ) // NewResource is a helper function to simplify the provider implementation. @@ -81,6 +82,11 @@ func (r *agentPolicyResource) buildFeatures(ctx context.Context) (features, diag return features{}, diagutil.FrameworkDiagsFromSDK(diags) } + supportsAgentFeatures, diags := r.client.EnforceMinVersion(ctx, MinVersionAgentFeatures) + if diags.HasError() { + return features{}, diagutil.FrameworkDiagsFromSDK(diags) + } + return features{ SupportsGlobalDataTags: supportsGDT, SupportsSupportsAgentless: supportsSupportsAgentless, @@ -88,5 +94,6 @@ func (r *agentPolicyResource) buildFeatures(ctx context.Context) (features, diag SupportsUnenrollmentTimeout: supportsUnenrollmentTimeout, SupportsSpaceIds: supportsSpaceIds, SupportsRequiredVersions: supportsRequiredVersions, + SupportsAgentFeatures: supportsAgentFeatures, }, nil } diff --git a/internal/fleet/agent_policy/schema.go b/internal/fleet/agent_policy/schema.go index cbcbe378e..f6b762c98 100644 --- a/internal/fleet/agent_policy/schema.go +++ b/internal/fleet/agent_policy/schema.go @@ -17,6 +17,7 @@ import ( "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/stringdefault" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" @@ -90,6 +91,15 @@ func getSchema() schema.Schema { Description: "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.", Optional: true, }, + "host_name_format": schema.StringAttribute{ + Description: "Determines the format of the host.name field in events. Can be 'hostname' (short hostname, e.g., 'myhost') or 'fqdn' (fully qualified domain name, e.g., 'myhost.example.com'). Defaults to 'hostname'.", + Computed: true, + Optional: true, + Default: stringdefault.StaticString(HostNameFormatHostname), + Validators: []validator.String{ + stringvalidator.OneOf(HostNameFormatHostname, HostNameFormatFQDN), + }, + }, "supports_agentless": schema.BoolAttribute{ Description: "Set to true to enable agentless data collection.", Optional: true, diff --git a/internal/fleet/agent_policy/testdata/TestAccResourceAgentPolicyWithHostNameFormat/create_with_fqdn/main.tf b/internal/fleet/agent_policy/testdata/TestAccResourceAgentPolicyWithHostNameFormat/create_with_fqdn/main.tf new file mode 100644 index 000000000..99ab70c23 --- /dev/null +++ b/internal/fleet/agent_policy/testdata/TestAccResourceAgentPolicyWithHostNameFormat/create_with_fqdn/main.tf @@ -0,0 +1,14 @@ +provider "elasticstack" { + elasticsearch {} + kibana {} +} + +resource "elasticstack_fleet_agent_policy" "test_policy" { + name = var.policy_name + namespace = "default" + description = "Test Agent Policy with FQDN host name format" + monitor_logs = true + monitor_metrics = false + host_name_format = "fqdn" +} + diff --git a/internal/fleet/agent_policy/testdata/TestAccResourceAgentPolicyWithHostNameFormat/create_with_fqdn/variables.tf b/internal/fleet/agent_policy/testdata/TestAccResourceAgentPolicyWithHostNameFormat/create_with_fqdn/variables.tf new file mode 100644 index 000000000..3e385a155 --- /dev/null +++ b/internal/fleet/agent_policy/testdata/TestAccResourceAgentPolicyWithHostNameFormat/create_with_fqdn/variables.tf @@ -0,0 +1,5 @@ +variable "policy_name" { + type = string + description = "Name for the agent policy" +} + diff --git a/internal/fleet/agent_policy/testdata/TestAccResourceAgentPolicyWithHostNameFormat/remove_host_name_format/main.tf b/internal/fleet/agent_policy/testdata/TestAccResourceAgentPolicyWithHostNameFormat/remove_host_name_format/main.tf new file mode 100644 index 000000000..184f49156 --- /dev/null +++ b/internal/fleet/agent_policy/testdata/TestAccResourceAgentPolicyWithHostNameFormat/remove_host_name_format/main.tf @@ -0,0 +1,13 @@ +provider "elasticstack" { + elasticsearch {} + kibana {} +} + +resource "elasticstack_fleet_agent_policy" "test_policy" { + name = var.policy_name + namespace = "default" + description = "Test Agent Policy without host_name_format" + monitor_logs = true + monitor_metrics = false +} + diff --git a/internal/fleet/agent_policy/testdata/TestAccResourceAgentPolicyWithHostNameFormat/remove_host_name_format/variables.tf b/internal/fleet/agent_policy/testdata/TestAccResourceAgentPolicyWithHostNameFormat/remove_host_name_format/variables.tf new file mode 100644 index 000000000..3e385a155 --- /dev/null +++ b/internal/fleet/agent_policy/testdata/TestAccResourceAgentPolicyWithHostNameFormat/remove_host_name_format/variables.tf @@ -0,0 +1,5 @@ +variable "policy_name" { + type = string + description = "Name for the agent policy" +} + diff --git a/internal/fleet/agent_policy/testdata/TestAccResourceAgentPolicyWithHostNameFormat/update_to_hostname/main.tf b/internal/fleet/agent_policy/testdata/TestAccResourceAgentPolicyWithHostNameFormat/update_to_hostname/main.tf new file mode 100644 index 000000000..bfb70ea17 --- /dev/null +++ b/internal/fleet/agent_policy/testdata/TestAccResourceAgentPolicyWithHostNameFormat/update_to_hostname/main.tf @@ -0,0 +1,14 @@ +provider "elasticstack" { + elasticsearch {} + kibana {} +} + +resource "elasticstack_fleet_agent_policy" "test_policy" { + name = var.policy_name + namespace = "default" + description = "Test Agent Policy with hostname format" + monitor_logs = true + monitor_metrics = false + host_name_format = "hostname" +} + diff --git a/internal/fleet/agent_policy/testdata/TestAccResourceAgentPolicyWithHostNameFormat/update_to_hostname/variables.tf b/internal/fleet/agent_policy/testdata/TestAccResourceAgentPolicyWithHostNameFormat/update_to_hostname/variables.tf new file mode 100644 index 000000000..3e385a155 --- /dev/null +++ b/internal/fleet/agent_policy/testdata/TestAccResourceAgentPolicyWithHostNameFormat/update_to_hostname/variables.tf @@ -0,0 +1,5 @@ +variable "policy_name" { + type = string + description = "Name for the agent policy" +} + diff --git a/internal/fleet/agent_policy/update.go b/internal/fleet/agent_policy/update.go index e68bc2ca3..5cf8123f4 100644 --- a/internal/fleet/agent_policy/update.go +++ b/internal/fleet/agent_policy/update.go @@ -29,13 +29,6 @@ func (r *agentPolicyResource) Update(ctx context.Context, req resource.UpdateReq return } - body, diags := planModel.toAPIUpdateModel(ctx, feat) - - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } - policyID := planModel.PolicyID.ValueString() // Read the existing spaces from state to avoid updating the policy @@ -49,6 +42,25 @@ func (r *agentPolicyResource) Update(ctx context.Context, req resource.UpdateReq return } + // Read current policy to get existing AgentFeatures (so we can preserve other features) + currentPolicy, diags := fleet.GetAgentPolicy(ctx, client, policyID, spaceID) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + var existingFeatures []apiAgentFeature + if currentPolicy != nil && currentPolicy.AgentFeatures != nil { + existingFeatures = *currentPolicy.AgentFeatures + } + + body, diags := planModel.toAPIUpdateModel(ctx, feat, existingFeatures) + + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + // Update using the operational space from STATE // The API will handle adding/removing the policy from spaces based on space_ids in body policy, diags := fleet.UpdateAgentPolicy(ctx, client, policyID, spaceID, body) diff --git a/internal/fleet/agent_policy/version_test.go b/internal/fleet/agent_policy/version_test.go index ab92c52bf..60aa25889 100644 --- a/internal/fleet/agent_policy/version_test.go +++ b/internal/fleet/agent_policy/version_test.go @@ -2,10 +2,11 @@ package agent_policy import ( "context" + "testing" + "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) { @@ -84,7 +85,7 @@ func TestInactivityTimeoutVersionValidation(t *testing.T) { } // Test toAPIUpdateModel - should return error when inactivity_timeout is used but not supported - _, diags = model.toAPIUpdateModel(ctx, feat) + _, diags = model.toAPIUpdateModel(ctx, feat, nil) if !diags.HasError() { t.Error("Expected error when using inactivity_timeout on unsupported version in update, but got none") } @@ -101,7 +102,7 @@ func TestInactivityTimeoutVersionValidation(t *testing.T) { } // Test toAPIUpdateModel - should NOT return error when inactivity_timeout is supported - _, diags = model.toAPIUpdateModel(ctx, featSupported) + _, diags = model.toAPIUpdateModel(ctx, featSupported, nil) if diags.HasError() { t.Errorf("Did not expect error when using inactivity_timeout on supported version in update: %v", diags) } @@ -120,7 +121,7 @@ func TestInactivityTimeoutVersionValidation(t *testing.T) { } // Test toAPIUpdateModel - should NOT return error when inactivity_timeout is not set, even on unsupported version - _, diags = modelWithoutTimeout.toAPIUpdateModel(ctx, feat) + _, diags = modelWithoutTimeout.toAPIUpdateModel(ctx, feat, nil) if diags.HasError() { t.Errorf("Did not expect error when inactivity_timeout is not set in update: %v", diags) } @@ -160,7 +161,7 @@ func TestUnenrollmentTimeoutVersionValidation(t *testing.T) { } // Test toAPIUpdateModel - should return error when unenrollment_timeout is used but not supported - _, diags = model.toAPIUpdateModel(ctx, feat) + _, diags = model.toAPIUpdateModel(ctx, feat, nil) if !diags.HasError() { t.Error("Expected error when using unenrollment_timeout on unsupported version in update, but got none") } @@ -177,7 +178,7 @@ func TestUnenrollmentTimeoutVersionValidation(t *testing.T) { } // Test toAPIUpdateModel - should NOT return error when unenrollment_timeout is supported - _, diags = model.toAPIUpdateModel(ctx, featSupported) + _, diags = model.toAPIUpdateModel(ctx, featSupported, nil) if diags.HasError() { t.Errorf("Did not expect error when using unenrollment_timeout on supported version in update: %v", diags) } @@ -196,7 +197,7 @@ func TestUnenrollmentTimeoutVersionValidation(t *testing.T) { } // Test toAPIUpdateModel - should NOT return error when unenrollment_timeout is not set, even on unsupported version - _, diags = modelWithoutTimeout.toAPIUpdateModel(ctx, feat) + _, diags = modelWithoutTimeout.toAPIUpdateModel(ctx, feat, nil) if diags.HasError() { t.Errorf("Did not expect error when unenrollment_timeout is not set in update: %v", diags) } @@ -258,7 +259,7 @@ func TestSpaceIdsVersionValidation(t *testing.T) { } // Test toAPIUpdateModel - should return error when space_ids is used but not supported - _, diags = model.toAPIUpdateModel(ctx, feat) + _, diags = model.toAPIUpdateModel(ctx, feat, nil) if !diags.HasError() { t.Error("Expected error when using space_ids on unsupported version in update, but got none") } @@ -275,7 +276,7 @@ func TestSpaceIdsVersionValidation(t *testing.T) { } // Test toAPIUpdateModel - should NOT return error when space_ids is supported - _, diags = model.toAPIUpdateModel(ctx, featSupported) + _, diags = model.toAPIUpdateModel(ctx, featSupported, nil) if diags.HasError() { t.Errorf("Did not expect error when using space_ids on supported version in update: %v", diags) } @@ -294,8 +295,124 @@ func TestSpaceIdsVersionValidation(t *testing.T) { } // Test toAPIUpdateModel - should NOT return error when space_ids is not set, even on unsupported version - _, diags = modelWithoutSpaceIds.toAPIUpdateModel(ctx, feat) + _, diags = modelWithoutSpaceIds.toAPIUpdateModel(ctx, feat, nil) if diags.HasError() { t.Errorf("Did not expect error when space_ids is not set in update: %v", diags) } } + +func TestMinVersionAgentFeatures(t *testing.T) { + // Test that the MinVersionAgentFeatures constant is set correctly + expected := "8.7.0" + actual := MinVersionAgentFeatures.String() + if actual != expected { + t.Errorf("Expected MinVersionAgentFeatures 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 MinVersionAgentFeatures.LessThan(olderVersion) { + t.Errorf("MinVersionAgentFeatures (%s) should be greater than %s", MinVersionAgentFeatures.String(), olderVersion.String()) + } + + // Test version comparison - should be less than 8.8.0 + newerVersion := version.Must(version.NewVersion("8.8.0")) + if MinVersionAgentFeatures.GreaterThan(newerVersion) { + t.Errorf("MinVersionAgentFeatures (%s) should be less than %s", MinVersionAgentFeatures.String(), newerVersion.String()) + } +} + +func TestAgentFeaturesVersionValidation(t *testing.T) { + ctx := context.Background() + + // Test case where agent_features is not supported (older version) with FQDN + model := &agentPolicyModel{ + Name: types.StringValue("test"), + Namespace: types.StringValue("default"), + HostNameFormat: types.StringValue(HostNameFormatFQDN), + } + + // Create features with agent_features NOT supported + feat := features{ + SupportsAgentFeatures: false, + } + + // Test toAPICreateModel - should return error when host_name_format=fqdn on unsupported version + _, diags := model.toAPICreateModel(ctx, feat) + if !diags.HasError() { + t.Error("Expected error when using host_name_format=fqdn 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 host_name_format=fqdn on unsupported version + _, diags = model.toAPIUpdateModel(ctx, feat, nil) + if !diags.HasError() { + t.Error("Expected error when using host_name_format=fqdn on unsupported version in update, but got none") + } + + // Test case where host_name_format=hostname (default) on unsupported version - should NOT error + modelWithHostname := &agentPolicyModel{ + Name: types.StringValue("test"), + Namespace: types.StringValue("default"), + HostNameFormat: types.StringValue(HostNameFormatHostname), + } + + // Test toAPICreateModel - should NOT return error for hostname (default) on unsupported version + _, diags = modelWithHostname.toAPICreateModel(ctx, feat) + if diags.HasError() { + t.Errorf("Did not expect error when using host_name_format=hostname on unsupported version: %v", diags) + } + + // Test toAPIUpdateModel - should NOT return error for hostname (default) on unsupported version + _, diags = modelWithHostname.toAPIUpdateModel(ctx, feat, nil) + if diags.HasError() { + t.Errorf("Did not expect error when using host_name_format=hostname on unsupported version in update: %v", diags) + } + + // Test case where agent_features IS supported (newer version) + featSupported := features{ + SupportsAgentFeatures: true, + } + + // Test toAPICreateModel - should NOT return error when agent_features is supported + _, diags = model.toAPICreateModel(ctx, featSupported) + if diags.HasError() { + t.Errorf("Did not expect error when using host_name_format on supported version: %v", diags) + } + + // Test toAPIUpdateModel - should NOT return error when agent_features is supported + _, diags = model.toAPIUpdateModel(ctx, featSupported, nil) + if diags.HasError() { + t.Errorf("Did not expect error when using host_name_format on supported version in update: %v", diags) + } + + // Test case where host_name_format is not set (should not cause validation errors) + modelWithoutHostNameFormat := &agentPolicyModel{ + Name: types.StringValue("test"), + Namespace: types.StringValue("default"), + // HostNameFormat is not set (null/unknown) + } + + // Test toAPICreateModel - should NOT return error when host_name_format is not set, even on unsupported version + _, diags = modelWithoutHostNameFormat.toAPICreateModel(ctx, feat) + if diags.HasError() { + t.Errorf("Did not expect error when host_name_format is not set: %v", diags) + } + + // Test toAPIUpdateModel - should NOT return error when host_name_format is not set, even on unsupported version + _, diags = modelWithoutHostNameFormat.toAPIUpdateModel(ctx, feat, nil) + if diags.HasError() { + t.Errorf("Did not expect error when host_name_format is not set in update: %v", diags) + } +}