diff --git a/CHANGELOG.md b/CHANGELOG.md index 91197a939..f50cf52ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ - Add support for `timeslice_metric_indicator` in `elasticstack_kibana_slo` ([#1195](https://github.com/elastic/terraform-provider-elasticstack/pull/1195)) - Add `elasticstack_elasticsearch_ingest_processor_reroute` data source ([#678](https://github.com/elastic/terraform-provider-elasticstack/issues/678)) +- Add `namespace` attribute to `elasticstack_kibana_synthetics_monitor` resource to support setting data stream namespace independently from `space_id` ([#1164](https://github.com/elastic/terraform-provider-elasticstack/issues/1164)) - Add support for `supports_agentless` to `elasticstack_fleet_agent_policy` ([#1197](https://github.com/elastic/terraform-provider-elasticstack/pull/1197)) - Ignore `master_timeout` when targeting Serverless projects ([#1207](https://github.com/elastic/terraform-provider-elasticstack/pull/1207)) diff --git a/docs/resources/kibana_synthetics_monitor.md b/docs/resources/kibana_synthetics_monitor.md index fabdec368..1ee3fdb25 100644 --- a/docs/resources/kibana_synthetics_monitor.md +++ b/docs/resources/kibana_synthetics_monitor.md @@ -72,12 +72,13 @@ resource "elasticstack_kibana_synthetics_monitor" "my_monitor" { - `http` (Attributes) HTTP Monitor specific fields (see [below for nested schema](#nestedatt--http)) - `icmp` (Attributes) ICMP Monitor specific fields (see [below for nested schema](#nestedatt--icmp)) - `locations` (List of String) Where to deploy the monitor. Monitors can be deployed in multiple locations so that you can detect differences in availability and response times across those locations. +- `namespace` (String) The data stream namespace. If not specified, defaults to the value of space_id. The namespace must be lowercase and not contain spaces. The namespace must not include any of the following characters: *, \, /, ?, ", <, >, |, whitespace, ,, #, :, or -. - `params` (String) Monitor parameters. Raw JSON object, use `jsonencode` function to represent JSON - `private_locations` (List of String) These Private Locations refer to locations hosted and managed by you, whereas locations are hosted by Elastic. You can specify a Private Location using the location’s name. - `retest_on_failure` (Boolean) Enable or disable retesting when a monitor fails. By default, monitors are automatically retested if the monitor goes from "up" to "down". If the result of the retest is also "down", an error will be created, and if configured, an alert sent. Then the monitor will resume running according to the defined schedule. Using retest_on_failure can reduce noise related to transient problems. Default: `true`. - `schedule` (Number) The monitor’s schedule in minutes. Supported values are 1, 3, 5, 10, 15, 30, 60, 120 and 240. - `service_name` (String) The APM service name. -- `space_id` (String) The namespace field should be lowercase and not contain spaces. The namespace must not include any of the following characters: *, \, /, ?, ", <, >, |, whitespace, ,, #, :, or -. Default: `default` +- `space_id` (String) An identifier for the space. If space_id is not provided, the default space is used. This value is used for the default for `namespace` when that attribute is not provided. - `tags` (List of String) An array of tags. - `tcp` (Attributes) TCP Monitor specific fields (see [below for nested schema](#nestedatt--tcp)) - `timeout` (Number) The monitor timeout in seconds, monitor will fail if it doesn’t complete within this time. Default: `16` diff --git a/internal/kibana/synthetics/acc_test.go b/internal/kibana/synthetics/acc_test.go index dca2ed096..4daa38015 100644 --- a/internal/kibana/synthetics/acc_test.go +++ b/internal/kibana/synthetics/acc_test.go @@ -9,6 +9,7 @@ import ( "github.com/hashicorp/go-version" sdkacctest "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" ) var ( @@ -53,6 +54,34 @@ resource "elasticstack_kibana_synthetics_monitor" "%s" { ipv6 = false } } +` + httpMonitorConfigWithNamespace = ` + +resource "elasticstack_kibana_synthetics_monitor" "%s" { + name = "TestHttpMonitorResource - %s" + space_id = "testacc" + namespace = "testnamespace" + schedule = 5 + private_locations = [elasticstack_kibana_synthetics_private_location.%s.label] + enabled = true + tags = ["a", "b"] + alert = { + status = { + enabled = true + } + tls = { + enabled = true + } + } + service_name = "test apm service" + timeout = 30 + http = { + url = "http://localhost:5601" + mode = "any" + ipv4 = true + ipv6 = false + } +} ` httpMonitorSslConfig = ` @@ -842,3 +871,41 @@ resource "elasticstack_kibana_synthetics_private_location" "%s" { return resourceId, provider + config } + +func TestSyntheticMonitorHTTPResourceWithNamespace(t *testing.T) { + + name := sdkacctest.RandStringFromCharSet(22, sdkacctest.CharSetAlphaNum) + id := "http-monitor-namespace" + httpMonitorId, config := testMonitorConfig(id, httpMonitorConfigWithNamespace, name) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ProtoV6ProviderFactories: acctest.Providers, + Steps: []resource.TestStep{ + // Create and Read http monitor with explicit namespace + { + SkipFunc: versionutils.CheckIfVersionIsUnsupported(minKibanaVersion), + Config: config, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet(httpMonitorId, "id"), + resource.TestCheckResourceAttr(httpMonitorId, "name", "TestHttpMonitorResource - "+name), + resource.TestCheckResourceAttr(httpMonitorId, "space_id", "testacc"), + resource.TestCheckResourceAttr(httpMonitorId, "namespace", "testnamespace"), + resource.TestCheckResourceAttr(httpMonitorId, "schedule", "5"), + resource.TestCheckResourceAttr(httpMonitorId, "enabled", "true"), + resource.TestCheckResourceAttr(httpMonitorId, "http.url", "http://localhost:5601"), + ), + }, + // Import + { + SkipFunc: versionutils.CheckIfVersionIsUnsupported(minKibanaVersion), + ResourceName: httpMonitorId, + ImportState: true, + ImportStateIdFunc: func(s *terraform.State) (string, error) { + return s.RootModule().Resources[httpMonitorId].Primary.Attributes["id"], nil + }, + ImportStateVerify: true, + }, + }, + }) +} diff --git a/internal/kibana/synthetics/create.go b/internal/kibana/synthetics/create.go index 830b9aa73..00a9e083f 100644 --- a/internal/kibana/synthetics/create.go +++ b/internal/kibana/synthetics/create.go @@ -27,13 +27,14 @@ func (r *Resource) Create(ctx context.Context, request resource.CreateRequest, r } namespace := plan.SpaceID.ValueString() + result, err := kibanaClient.KibanaSynthetics.Monitor.Add(ctx, input.config, input.fields, namespace) if err != nil { response.Diagnostics.AddError(fmt.Sprintf("Failed to create Kibana monitor `%s`, namespace %s", input.config.Name, namespace), err.Error()) return } - plan, diags = plan.toModelV0(ctx, result) + plan, diags = plan.toModelV0(ctx, result, namespace) response.Diagnostics.Append(diags...) if response.Diagnostics.HasError() { return diff --git a/internal/kibana/synthetics/parameter/schema_test.go b/internal/kibana/synthetics/parameter/schema_test.go index 1eae2009e..a01b795d2 100644 --- a/internal/kibana/synthetics/parameter/schema_test.go +++ b/internal/kibana/synthetics/parameter/schema_test.go @@ -18,7 +18,7 @@ func Test_roundtrip(t *testing.T) { { name: "only required fields", id: "id-1", - namespaces: []string{"ns-1"}, + namespaces: []string{"ns1"}, request: kboapi.SyntheticsParameterRequest{ Key: "key-1", Value: "value-1", @@ -39,7 +39,7 @@ func Test_roundtrip(t *testing.T) { { name: "only description", id: "id-3", - namespaces: []string{"ns-3"}, + namespaces: []string{"ns3"}, request: kboapi.SyntheticsParameterRequest{ Key: "key-3", Value: "value-3", @@ -49,7 +49,7 @@ func Test_roundtrip(t *testing.T) { { name: "only tags", id: "id-4", - namespaces: []string{"ns-4"}, + namespaces: []string{"ns4"}, request: kboapi.SyntheticsParameterRequest{ Key: "key-4", Value: "value-4", @@ -59,7 +59,7 @@ func Test_roundtrip(t *testing.T) { { name: "all namespaces", id: "id-5", - namespaces: []string{"ns-5"}, + namespaces: []string{"ns5"}, request: kboapi.SyntheticsParameterRequest{ Key: "key-5", Value: "value-5", diff --git a/internal/kibana/synthetics/read.go b/internal/kibana/synthetics/read.go index e7a13db7f..a07fc9e4b 100644 --- a/internal/kibana/synthetics/read.go +++ b/internal/kibana/synthetics/read.go @@ -42,7 +42,7 @@ func (r *Resource) Read(ctx context.Context, request resource.ReadRequest, respo return } - state, diags = state.toModelV0(ctx, result) + state, diags = state.toModelV0(ctx, result, namespace) response.Diagnostics.Append(diags...) if response.Diagnostics.HasError() { return diff --git a/internal/kibana/synthetics/schema.go b/internal/kibana/synthetics/schema.go index e77fce16e..a8216a236 100644 --- a/internal/kibana/synthetics/schema.go +++ b/internal/kibana/synthetics/schema.go @@ -4,11 +4,13 @@ import ( "context" "encoding/json" "fmt" + "regexp" "strconv" "github.com/disaster37/go-kibana-rest/v8/kbapi" "github.com/elastic/terraform-provider-elasticstack/internal/clients" "github.com/elastic/terraform-provider-elasticstack/internal/utils" + "github.com/elastic/terraform-provider-elasticstack/internal/utils/planmodifiers" "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" @@ -99,6 +101,7 @@ type tfModelV0 struct { ID types.String `tfsdk:"id"` Name types.String `tfsdk:"name"` SpaceID types.String `tfsdk:"space_id"` + Namespace types.String `tfsdk:"namespace"` Schedule types.Int64 `tfsdk:"schedule"` Locations []types.String `tfsdk:"locations"` PrivateLocations []types.String `tfsdk:"private_locations"` @@ -143,12 +146,28 @@ func monitorConfigSchema() schema.Schema { MarkdownDescription: "The monitor’s name.", }, "space_id": schema.StringAttribute{ - MarkdownDescription: "The namespace field should be lowercase and not contain spaces. The namespace must not include any of the following characters: *, \\, /, ?, \", <, >, |, whitespace, ,, #, :, or -. Default: `default`", + MarkdownDescription: "An identifier for the space. If space_id is not provided, the default space is used. This value is used for the default for `namespace` when that attribute is not provided.", + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + planmodifiers.StringUseDefaultIfUnknown("default"), + requiresReplaceIfSpaceIdChanged(), + }, + Computed: true, + }, + "namespace": schema.StringAttribute{ + MarkdownDescription: "The data stream namespace. If not specified, defaults to the value of space_id. The namespace must be lowercase and not contain spaces. The namespace must not include any of the following characters: *, \\, /, ?, \", <, >, |, whitespace, ,, #, :, or -.", Optional: true, PlanModifiers: []planmodifier.String{ stringplanmodifier.UseStateForUnknown(), stringplanmodifier.RequiresReplace(), }, + Validators: []validator.String{ + stringvalidator.RegexMatches( + regexp.MustCompile(`^[^*\\/?\"<>|\s,#:-]*$`), + "namespace must not contain any of the following characters: *, \\, /, ?, \", <, >, |, whitespace, ,, #, :, or -", + ), + }, Computed: true, }, "schedule": schema.Int64Attribute{ @@ -566,7 +585,7 @@ func stringToInt64(v string) (int64, error) { return res, err } -func (v *tfModelV0) toModelV0(ctx context.Context, api *kbapi.SyntheticsMonitor) (*tfModelV0, diag.Diagnostics) { +func (v *tfModelV0) toModelV0(ctx context.Context, api *kbapi.SyntheticsMonitor, spaceID string) (*tfModelV0, diag.Diagnostics) { var schedule int64 var err error dg := diag.Diagnostics{} @@ -640,7 +659,7 @@ func (v *tfModelV0) toModelV0(ctx context.Context, api *kbapi.SyntheticsMonitor) } resourceID := clients.CompositeId{ - ClusterId: api.Namespace, + ClusterId: spaceID, ResourceId: string(api.Id), } @@ -652,7 +671,8 @@ func (v *tfModelV0) toModelV0(ctx context.Context, api *kbapi.SyntheticsMonitor) return &tfModelV0{ ID: types.StringValue(resourceID.String()), Name: types.StringValue(api.Name), - SpaceID: types.StringValue(api.Namespace), + SpaceID: types.StringValue(spaceID), + Namespace: types.StringValue(api.Namespace), Schedule: types.Int64Value(schedule), Locations: v.Locations, PrivateLocations: StringSliceValue(privateLocLabels), @@ -873,6 +893,12 @@ func (v *tfModelV0) toSyntheticsMonitorConfig(ctx context.Context) (*kbapi.Synth return nil, dg } + // Use namespace if explicitly set, otherwise fall back to space_id + namespace := v.Namespace.ValueString() + if namespace == "" || v.Namespace.IsNull() || v.Namespace.IsUnknown() { + namespace = v.SpaceID.ValueString() + } + return &kbapi.SyntheticsMonitorConfig{ Name: v.Name.ValueString(), Schedule: kbapi.MonitorSchedule(v.Schedule.ValueInt64()), @@ -883,7 +909,7 @@ func (v *tfModelV0) toSyntheticsMonitorConfig(ctx context.Context) (*kbapi.Synth Alert: toTFAlertConfig(ctx, v.Alert), APMServiceName: v.APMServiceName.ValueString(), TimeoutSeconds: int(v.TimeoutSeconds.ValueInt64()), - Namespace: v.SpaceID.ValueString(), + Namespace: namespace, Params: params, RetestOnFailure: v.RetestOnFailure.ValueBoolPointer(), }, diag.Diagnostics{} //dg @@ -1047,3 +1073,58 @@ func (v tfStatusConfigV0) toTfStatusConfigV0() *kbapi.SyntheticsStatusConfig { Enabled: v.Enabled.ValueBoolPointer(), } } + +func requiresReplaceIfSpaceIdChanged() planmodifier.String { + return stringplanmodifier.RequiresReplaceIf( + func(ctx context.Context, req planmodifier.StringRequest, resp *stringplanmodifier.RequiresReplaceIfFuncResponse) { + // Don't require replace if plan value is unknown + if req.PlanValue.IsUnknown() { + resp.RequiresReplace = false + return + } + + // Don't require replace if state value is null (creating) + if req.StateValue.IsNull() { + resp.RequiresReplace = false + return + } + + // Don't require replace if config value is null (not configured by user) + if req.ConfigValue.IsNull() { + resp.RequiresReplace = false + return + } + + stateValue := req.StateValue.ValueString() + planValue := req.PlanValue.ValueString() + + // Don't require replace if values are the same + if stateValue == planValue { + resp.RequiresReplace = false + return + } + + // Normalize empty and "default" values for comparison + normalizeValue := func(v string) string { + if v == "" || v == "default" { + return "default" + } + return v + } + + normalizedState := normalizeValue(stateValue) + normalizedPlan := normalizeValue(planValue) + + // Don't require replace if the change is between empty/"" and "default" + if normalizedState == normalizedPlan { + resp.RequiresReplace = false + return + } + + // Otherwise, require replace + resp.RequiresReplace = true + }, + "Requires replace if the space_id changes, except when changing between empty and 'default'", + "Requires replace if the space_id changes, except when changing between empty and 'default'", + ) +} diff --git a/internal/kibana/synthetics/schema_test.go b/internal/kibana/synthetics/schema_test.go index 1711530bf..59bdb0261 100644 --- a/internal/kibana/synthetics/schema_test.go +++ b/internal/kibana/synthetics/schema_test.go @@ -49,6 +49,7 @@ func TestToModelV0(t *testing.T) { ID: types.StringValue("/"), Name: types.StringValue(""), SpaceID: types.StringValue(""), + Namespace: types.StringValue(""), Schedule: types.Int64Value(0), APMServiceName: types.StringValue(""), TimeoutSeconds: types.Int64Value(0), @@ -83,6 +84,7 @@ func TestToModelV0(t *testing.T) { ID: types.StringValue("/"), Name: types.StringValue(""), SpaceID: types.StringValue(""), + Namespace: types.StringValue(""), Schedule: types.Int64Value(0), APMServiceName: types.StringValue(""), TimeoutSeconds: types.Int64Value(0), @@ -111,6 +113,7 @@ func TestToModelV0(t *testing.T) { ID: types.StringValue("/"), Name: types.StringValue(""), SpaceID: types.StringValue(""), + Namespace: types.StringValue(""), Schedule: types.Int64Value(0), APMServiceName: types.StringValue(""), TimeoutSeconds: types.Int64Value(0), @@ -130,6 +133,7 @@ func TestToModelV0(t *testing.T) { ID: types.StringValue("/"), Name: types.StringValue(""), SpaceID: types.StringValue(""), + Namespace: types.StringValue(""), Schedule: types.Int64Value(0), APMServiceName: types.StringValue(""), TimeoutSeconds: types.Int64Value(0), @@ -191,6 +195,7 @@ func TestToModelV0(t *testing.T) { ID: types.StringValue("default/test-id-http"), Name: types.StringValue("test-name-http"), SpaceID: types.StringValue("default"), + Namespace: types.StringValue("default"), Schedule: types.Int64Value(5), Locations: []types.String{types.StringValue("us_east")}, PrivateLocations: []types.String{types.StringValue("test private location")}, @@ -261,6 +266,7 @@ func TestToModelV0(t *testing.T) { ID: types.StringValue("default/test-id-tcp"), Name: types.StringValue("test-name-tcp"), SpaceID: types.StringValue("default"), + Namespace: types.StringValue("default"), Schedule: types.Int64Value(5), Locations: nil, PrivateLocations: []types.String{types.StringValue("test private location")}, @@ -320,6 +326,7 @@ func TestToModelV0(t *testing.T) { ID: types.StringValue("default/test-id-icmp"), Name: types.StringValue("test-name-icmp"), SpaceID: types.StringValue("default"), + Namespace: types.StringValue("default"), Schedule: types.Int64Value(5), Locations: nil, PrivateLocations: []types.String{types.StringValue("test private location")}, @@ -375,6 +382,7 @@ func TestToModelV0(t *testing.T) { ID: types.StringValue("default/test-id-browser"), Name: types.StringValue("test-name-browser"), SpaceID: types.StringValue("default"), + Namespace: types.StringValue("default"), Schedule: types.Int64Value(5), Locations: nil, PrivateLocations: []types.String{types.StringValue("test private location")}, @@ -398,7 +406,8 @@ func TestToModelV0(t *testing.T) { for _, tt := range testcases { t.Run(tt.name, func(t *testing.T) { ctx := context.Background() - model, diag := tt.expected.toModelV0(ctx, &tt.input) + expectedSpaceID := tt.expected.SpaceID.ValueString() + model, diag := tt.expected.toModelV0(ctx, &tt.input, expectedSpaceID) assert.False(t, diag.HasError()) assert.Equal(t, &tt.expected, model) }) @@ -457,6 +466,7 @@ func TestToKibanaAPIRequest(t *testing.T) { ID: types.StringValue("test-id-http"), Name: types.StringValue("test-name-http"), SpaceID: types.StringValue("default"), + Namespace: types.StringValue("default"), Schedule: types.Int64Value(5), Locations: []types.String{types.StringValue("us_east")}, PrivateLocations: []types.String{types.StringValue("test private location")}, @@ -533,6 +543,7 @@ func TestToKibanaAPIRequest(t *testing.T) { ID: types.StringValue("test-id-tcp"), Name: types.StringValue("test-name-tcp"), SpaceID: types.StringValue("default"), + Namespace: types.StringValue("default"), Schedule: types.Int64Value(5), Locations: []types.String{types.StringValue("us_east")}, PrivateLocations: nil, @@ -597,6 +608,7 @@ func TestToKibanaAPIRequest(t *testing.T) { ID: types.StringValue("test-id-icmp"), Name: types.StringValue("test-name-icmp"), SpaceID: types.StringValue("default"), + Namespace: types.StringValue("default"), Schedule: types.Int64Value(5), Locations: []types.String{types.StringValue("us_east")}, PrivateLocations: nil, @@ -637,6 +649,7 @@ func TestToKibanaAPIRequest(t *testing.T) { ID: types.StringValue("test-id-browser"), Name: types.StringValue("test-name-browser"), SpaceID: types.StringValue("default"), + Namespace: types.StringValue("default"), Schedule: types.Int64Value(5), Locations: []types.String{types.StringValue("us_east")}, PrivateLocations: nil, @@ -722,6 +735,7 @@ func TestToModelV0MergeAttributes(t *testing.T) { ID: types.StringValue("/"), Name: types.StringValue(""), SpaceID: types.StringValue(""), + Namespace: types.StringValue(""), Schedule: types.Int64Value(0), APMServiceName: types.StringValue(""), TimeoutSeconds: types.Int64Value(0), @@ -767,6 +781,7 @@ func TestToModelV0MergeAttributes(t *testing.T) { ID: types.StringValue("/"), Name: types.StringValue(""), SpaceID: types.StringValue(""), + Namespace: types.StringValue(""), Schedule: types.Int64Value(0), APMServiceName: types.StringValue(""), TimeoutSeconds: types.Int64Value(0), @@ -801,6 +816,7 @@ func TestToModelV0MergeAttributes(t *testing.T) { ID: types.StringValue("/"), Name: types.StringValue(""), SpaceID: types.StringValue(""), + Namespace: types.StringValue(""), Schedule: types.Int64Value(0), APMServiceName: types.StringValue(""), TimeoutSeconds: types.Int64Value(0), @@ -816,7 +832,8 @@ func TestToModelV0MergeAttributes(t *testing.T) { for _, tt := range testcases { t.Run(tt.name, func(t *testing.T) { ctx := context.Background() - actual, diag := tt.state.toModelV0(ctx, &tt.input) + expectedSpaceID := tt.expected.SpaceID.ValueString() + actual, diag := tt.state.toModelV0(ctx, &tt.input, expectedSpaceID) assert.False(t, diag.HasError()) assert.NotNil(t, actual) assert.Equal(t, &tt.expected, actual) diff --git a/internal/kibana/synthetics/update.go b/internal/kibana/synthetics/update.go index c7544622b..bc936566c 100644 --- a/internal/kibana/synthetics/update.go +++ b/internal/kibana/synthetics/update.go @@ -34,13 +34,14 @@ func (r *Resource) Update(ctx context.Context, request resource.UpdateRequest, r } namespace := plan.SpaceID.ValueString() + result, err := kibanaClient.KibanaSynthetics.Monitor.Update(ctx, kbapi.MonitorID(monitorId.ResourceId), input.config, input.fields, namespace) if err != nil { response.Diagnostics.AddError(fmt.Sprintf("Failed to update Kibana monitor `%s`, namespace %s", input.config.Name, namespace), err.Error()) return } - plan, diags = plan.toModelV0(ctx, result) + plan, diags = plan.toModelV0(ctx, result, namespace) response.Diagnostics.Append(diags...) if response.Diagnostics.HasError() { return