diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index a165ceb63e..da7f2fa215 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -102,7 +102,7 @@ jobs:
- '8.11.4'
- '8.12.2'
- '8.13.4'
- - '8.14.0'
+ - '8.14.3'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f097c23c8a..9201c9ce3a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
- Fix setting `id` for Fleet outputs and servers ([#666](https://github.com/elastic/terraform-provider-elasticstack/pull/666))
- Fix `elasticstack_fleet_enrollment_tokens` returning empty tokens in some case ([#683](https://github.com/elastic/terraform-provider-elasticstack/pull/683))
+- Add support for Kibana synthetics private locations ([#696](https://github.com/elastic/terraform-provider-elasticstack/pull/696))
## [0.11.4] - 2024-06-13
diff --git a/Makefile b/Makefile
index c6cbb5a4a2..3235784d5a 100644
--- a/Makefile
+++ b/Makefile
@@ -58,8 +58,8 @@ testacc: ## Run acceptance tests
test: ## Run unit tests
go test -v $(TEST) $(TESTARGS) -timeout=5m -parallel=4
-# Retry command - first argumment is how many attempts are required, second argument is the command to run
-# Backoff starts with 1 second and double with next itteration
+# Retry command - first argument is how many attempts are required, second argument is the command to run
+# Backoff starts with 1 second and double with next iteration
retry = until [ $$(if [ -z "$$attempt" ]; then echo -n "0"; else echo -n "$$attempt"; fi) -ge $(1) ]; do \
backoff=$$(if [ -z "$$backoff" ]; then echo "1"; else echo "$$backoff"; fi); \
sleep $$backoff; \
diff --git a/docs/resources/kibana_synthetics_private_location b/docs/resources/kibana_synthetics_private_location
new file mode 100644
index 0000000000..ee498c76af
--- /dev/null
+++ b/docs/resources/kibana_synthetics_private_location
@@ -0,0 +1,76 @@
+---
+subcategory: "Kibana"
+layout: ""
+page_title: "Elasticstack: elasticstack_kibana_synthetics_private_location Resource"
+description: |-
+ Creates or updates a Kibana synthetics private location.
+---
+
+# Resource: elasticstack_kibana_synthetics_private_location
+
+Creates or updates a Kibana synthetics private location.
+See [Monitor via a private agent](https://www.elastic.co/guide/en/observability/current/synthetics-private-location.html#monitor-via-private-agent)
+and [api docs](https://www.elastic.co/guide/en/kibana/current/create-private-location-api.html)
+
+## Example Usage
+
+```terraform
+provider "elasticstack" {
+ fleet {}
+ kibana {}
+}
+
+resource "elasticstack_fleet_agent_policy" "sample" {
+ name = "Sample Agent Policy"
+ namespace = "default"
+ description = "A sample agent policy"
+ monitor_logs = true
+ monitor_metrics = true
+ skip_destroy = false
+}
+
+resource "elasticstack_kibana_synthetics_private_location" "example" {
+ label = "example label"
+ space_id = "default"
+ agent_policy_id = elasticstack_fleet_agent_policy.sample.policy_id
+ tags = ["tag-a", "tag-b"]
+ geo = {
+ lat = 40.7128
+ lon = 74.0060
+ }
+}
+```
+
+
+## Schema
+
+### Required
+
+- `agent_policy_id` (String) The ID of the agent policy associated with the private location. To create a private location for synthetics monitor you need to create an agent policy in fleet and use its agentPolicyId
+- `label` (String) A label for the private location, used as unique identifier
+
+### Optional
+
+- `geo` (Attributes) Geographic coordinates (WGS84) for the location (see [below for nested schema](#nestedatt--geo))
+- `space_id` (String) An identifier for the space. If space_id is not provided, the default space is used.
+- `tags` (List of String) An array of tags to categorize the private location.
+
+### Read-Only
+
+- `id` (String) Generated id for the private location. For monitor setup please use private location label.
+
+
+### Nested Schema for `geo`
+
+Required:
+
+- `lat` (Number) The latitude of the location.
+- `lon` (Number) The longitude of the location.
+
+## Import
+
+Import is supported using the following syntax:
+
+```shell
+terraform import elasticstack_kibana_synthetics_private_location.my_location
+```
\ No newline at end of file
diff --git a/docs/resources/kibana_synthetics_private_location.md b/docs/resources/kibana_synthetics_private_location.md
new file mode 100644
index 0000000000..0048150519
--- /dev/null
+++ b/docs/resources/kibana_synthetics_private_location.md
@@ -0,0 +1,74 @@
+---
+# generated by https://github.com/hashicorp/terraform-plugin-docs
+page_title: "elasticstack_kibana_synthetics_private_location Resource - terraform-provider-elasticstack"
+subcategory: ""
+description: |-
+ Synthetics private location config, see https://www.elastic.co/guide/en/kibana/current/create-private-location-api.html for more details
+---
+
+# elasticstack_kibana_synthetics_private_location (Resource)
+
+Synthetics private location config, see https://www.elastic.co/guide/en/kibana/current/create-private-location-api.html for more details
+
+## Example Usage
+
+```terraform
+provider "elasticstack" {
+ fleet {}
+ kibana {}
+}
+
+resource "elasticstack_fleet_agent_policy" "sample" {
+ name = "Sample Agent Policy"
+ namespace = "default"
+ description = "A sample agent policy"
+ monitor_logs = true
+ monitor_metrics = true
+ skip_destroy = false
+}
+
+resource "elasticstack_kibana_synthetics_private_location" "example" {
+ label = "example label"
+ space_id = "default"
+ agent_policy_id = elasticstack_fleet_agent_policy.sample.policy_id
+ tags = ["tag-a", "tag-b"]
+ geo = {
+ lat = 40.7128
+ lon = 74.0060
+ }
+}
+```
+
+
+## Schema
+
+### Required
+
+- `agent_policy_id` (String) The ID of the agent policy associated with the private location. To create a private location for synthetics monitor you need to create an agent policy in fleet and use its agentPolicyId
+- `label` (String) A label for the private location, used as unique identifier
+
+### Optional
+
+- `geo` (Attributes) Geographic coordinates (WGS84) for the location (see [below for nested schema](#nestedatt--geo))
+- `space_id` (String) An identifier for the space. If space_id is not provided, the default space is used.
+- `tags` (List of String) An array of tags to categorize the private location.
+
+### Read-Only
+
+- `id` (String) Generated id for the private location. For monitor setup please use private location label.
+
+
+### Nested Schema for `geo`
+
+Required:
+
+- `lat` (Number) The latitude of the location.
+- `lon` (Number) The longitude of the location.
+
+## Import
+
+Import is supported using the following syntax:
+
+```shell
+terraform import elasticstack_kibana_synthetics_private_location.my_location
+```
diff --git a/examples/resources/elasticstack_kibana_synthetics_private_location/import.sh b/examples/resources/elasticstack_kibana_synthetics_private_location/import.sh
new file mode 100644
index 0000000000..628bacff47
--- /dev/null
+++ b/examples/resources/elasticstack_kibana_synthetics_private_location/import.sh
@@ -0,0 +1 @@
+terraform import elasticstack_kibana_synthetics_private_location.my_location
diff --git a/examples/resources/elasticstack_kibana_synthetics_private_location/resource.tf b/examples/resources/elasticstack_kibana_synthetics_private_location/resource.tf
new file mode 100644
index 0000000000..933485ee36
--- /dev/null
+++ b/examples/resources/elasticstack_kibana_synthetics_private_location/resource.tf
@@ -0,0 +1,24 @@
+provider "elasticstack" {
+ fleet {}
+ kibana {}
+}
+
+resource "elasticstack_fleet_agent_policy" "sample" {
+ name = "Sample Agent Policy"
+ namespace = "default"
+ description = "A sample agent policy"
+ monitor_logs = true
+ monitor_metrics = true
+ skip_destroy = false
+}
+
+resource "elasticstack_kibana_synthetics_private_location" "example" {
+ label = "example label"
+ space_id = "default"
+ agent_policy_id = elasticstack_fleet_agent_policy.sample.policy_id
+ tags = ["tag-a", "tag-b"]
+ geo = {
+ lat = 40.7128
+ lon = 74.0060
+ }
+}
diff --git a/internal/clients/api_client.go b/internal/clients/api_client.go
index e60e8d349d..f43eaec22d 100644
--- a/internal/clients/api_client.go
+++ b/internal/clients/api_client.go
@@ -391,8 +391,12 @@ func buildKibanaClient(cfg config.Client) (*kibana.Client, error) {
if logging.IsDebugOrHigher() {
// Don't use kib.Client.SetDebug() here as we re-use the http client within the OpenAPI generated clients
- kibHttpClient := kib.Client.GetClient()
- kibHttpClient.Transport = utils.NewDebugTransport("Kibana", kibHttpClient.Transport)
+ transport, err := kib.Client.Transport()
+ if err != nil {
+ return nil, err
+ }
+ var roundTripper http.RoundTripper = utils.NewDebugTransport("Kibana", transport)
+ kib.Client.SetTransport(roundTripper)
}
return kib, nil
diff --git a/internal/kibana/synthetics/private_location/acc_test.go b/internal/kibana/synthetics/private_location/acc_test.go
new file mode 100644
index 0000000000..57995d7f59
--- /dev/null
+++ b/internal/kibana/synthetics/private_location/acc_test.go
@@ -0,0 +1,184 @@
+package private_location_test
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/elastic/terraform-provider-elasticstack/internal/acctest"
+ "github.com/elastic/terraform-provider-elasticstack/internal/versionutils"
+ "github.com/hashicorp/go-version"
+ "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
+)
+
+const (
+ providerConfig = `
+provider "elasticstack" {
+ elasticsearch {}
+ kibana {}
+ fleet{}
+}
+`
+)
+
+var (
+ minKibanaVersion = version.Must(version.NewVersion("8.12.0"))
+)
+
+func TestPrivateLocationResource(t *testing.T) {
+ resourceId := "elasticstack_kibana_synthetics_private_location.test"
+ resource.Test(t, resource.TestCase{
+ PreCheck: func() { acctest.PreCheck(t) },
+ ProtoV6ProviderFactories: acctest.Providers,
+ Steps: []resource.TestStep{
+ // Create and Read testing
+ {
+ SkipFunc: versionutils.CheckIfVersionIsUnsupported(minKibanaVersion),
+ Config: testConfig("testacc", "test_policy") + `
+resource "elasticstack_kibana_synthetics_private_location" "test" {
+ label = "pl-test-label"
+ space_id = "testacc"
+ agent_policy_id = elasticstack_fleet_agent_policy.test_policy.policy_id
+ tags = ["a", "b"]
+ geo = {
+ lat = 42.42
+ lon = -42.42
+ }
+}
+`,
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckResourceAttr(resourceId, "label", "pl-test-label"),
+ resource.TestCheckResourceAttr(resourceId, "space_id", "testacc"),
+ resource.TestCheckResourceAttrSet(resourceId, "agent_policy_id"),
+ resource.TestCheckResourceAttr(resourceId, "tags.#", "2"),
+ resource.TestCheckResourceAttr(resourceId, "tags.0", "a"),
+ resource.TestCheckResourceAttr(resourceId, "tags.1", "b"),
+ resource.TestCheckResourceAttr(resourceId, "geo.lat", "42.42"),
+ resource.TestCheckResourceAttr(resourceId, "geo.lon", "-42.42"),
+ ),
+ },
+ // ImportState testing
+ {
+ SkipFunc: versionutils.CheckIfVersionIsUnsupported(minKibanaVersion),
+ ResourceName: resourceId,
+ ImportState: true,
+ ImportStateVerify: true,
+ Config: testConfig("testacc", "test_policy") + `
+resource "elasticstack_kibana_synthetics_private_location" "test" {
+ label = "pl-test-label"
+ space_id = "testacc"
+ agent_policy_id = elasticstack_fleet_agent_policy.test_policy.policy_id
+ tags = ["a", "b"]
+ geo = {
+ lat = 42.42
+ lon = -42.42
+ }
+}
+`,
+ },
+ // Update and Read testing
+ {
+ SkipFunc: versionutils.CheckIfVersionIsUnsupported(minKibanaVersion),
+ Config: testConfig("default", "test_policy_default") + `
+resource "elasticstack_kibana_synthetics_private_location" "test" {
+ label = "pl-test-label-2"
+ space_id = "default"
+ agent_policy_id = elasticstack_fleet_agent_policy.test_policy_default.policy_id
+ tags = ["c", "d", "e"]
+ geo = {
+ lat = -33.21
+ lon = -33.21
+ }
+}
+`,
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckResourceAttr(resourceId, "label", "pl-test-label-2"),
+ resource.TestCheckResourceAttr(resourceId, "space_id", "default"),
+ resource.TestCheckResourceAttrSet(resourceId, "agent_policy_id"),
+ resource.TestCheckResourceAttr(resourceId, "tags.#", "3"),
+ resource.TestCheckResourceAttr(resourceId, "tags.0", "c"),
+ resource.TestCheckResourceAttr(resourceId, "tags.1", "d"),
+ resource.TestCheckResourceAttr(resourceId, "tags.2", "e"),
+ resource.TestCheckResourceAttr(resourceId, "geo.lat", "-33.21"),
+ resource.TestCheckResourceAttr(resourceId, "geo.lon", "-33.21"),
+ ),
+ },
+ // Update and Read testing
+ {
+ SkipFunc: versionutils.CheckIfVersionIsUnsupported(minKibanaVersion),
+ Config: testConfig("default", "test_policy_default") + `
+resource "elasticstack_kibana_synthetics_private_location" "test" {
+ label = "pl-test-label-2"
+ space_id = "default"
+ agent_policy_id = elasticstack_fleet_agent_policy.test_policy_default.policy_id
+}
+`,
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckResourceAttr(resourceId, "label", "pl-test-label-2"),
+ resource.TestCheckResourceAttr(resourceId, "space_id", "default"),
+ resource.TestCheckResourceAttrSet(resourceId, "agent_policy_id"),
+ resource.TestCheckNoResourceAttr(resourceId, "tags"),
+ resource.TestCheckNoResourceAttr(resourceId, "geo"),
+ ),
+ },
+ // Update and Read testing
+ {
+ SkipFunc: versionutils.CheckIfVersionIsUnsupported(minKibanaVersion),
+ Config: testConfig("default", "test_policy_default") + `
+resource "elasticstack_kibana_synthetics_private_location" "test" {
+ label = "pl-test-label-2"
+ space_id = "default"
+ agent_policy_id = elasticstack_fleet_agent_policy.test_policy_default.policy_id
+ tags = ["c", "d", "e"]
+}
+`,
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckResourceAttr(resourceId, "label", "pl-test-label-2"),
+ resource.TestCheckResourceAttr(resourceId, "space_id", "default"),
+ resource.TestCheckResourceAttrSet(resourceId, "agent_policy_id"),
+ resource.TestCheckResourceAttr(resourceId, "tags.#", "3"),
+ resource.TestCheckResourceAttr(resourceId, "tags.0", "c"),
+ resource.TestCheckResourceAttr(resourceId, "tags.1", "d"),
+ resource.TestCheckResourceAttr(resourceId, "tags.2", "e"),
+ resource.TestCheckNoResourceAttr(resourceId, "geo"),
+ ),
+ },
+ // Update and Read testing
+ {
+ SkipFunc: versionutils.CheckIfVersionIsUnsupported(minKibanaVersion),
+ Config: testConfig("default", "test_policy_default") + `
+resource "elasticstack_kibana_synthetics_private_location" "test" {
+ label = "pl-test-label-2"
+ space_id = "default"
+ agent_policy_id = elasticstack_fleet_agent_policy.test_policy_default.policy_id
+ geo = {
+ lat = -33.21
+ lon = -33.21
+ }
+}
+`,
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckResourceAttr(resourceId, "label", "pl-test-label-2"),
+ resource.TestCheckResourceAttr(resourceId, "space_id", "default"),
+ resource.TestCheckResourceAttrSet(resourceId, "agent_policy_id"),
+ resource.TestCheckNoResourceAttr(resourceId, "tags"),
+ resource.TestCheckResourceAttr(resourceId, "geo.lat", "-33.21"),
+ resource.TestCheckResourceAttr(resourceId, "geo.lon", "-33.21"),
+ ),
+ },
+ // Delete testing automatically occurs in TestCase
+ },
+ })
+}
+
+func testConfig(namespace, agentPolicy string) string {
+ return providerConfig + fmt.Sprintf(`
+resource "elasticstack_fleet_agent_policy" "%s" {
+ name = "Private Location Agent Policy - %s"
+ namespace = "%s"
+ description = "TestPrivateLocationResource Agent Policy"
+ monitor_logs = true
+ monitor_metrics = true
+ skip_destroy = false
+}
+`, agentPolicy, agentPolicy, namespace)
+}
diff --git a/internal/kibana/synthetics/private_location/create.go b/internal/kibana/synthetics/private_location/create.go
new file mode 100644
index 0000000000..9f0bf2aa3d
--- /dev/null
+++ b/internal/kibana/synthetics/private_location/create.go
@@ -0,0 +1,42 @@
+package private_location
+
+import (
+ "context"
+ "fmt"
+ "github.com/hashicorp/terraform-plugin-framework/resource"
+ "github.com/hashicorp/terraform-plugin-log/tflog"
+)
+
+func (r *Resource) Create(ctx context.Context, request resource.CreateRequest, response *resource.CreateResponse) {
+
+ tflog.Info(ctx, "Create private location")
+
+ kibanaClient := r.getKibanaClient(response.Diagnostics)
+ if kibanaClient == nil {
+ return
+ }
+
+ var plan tfModelV0
+ diags := request.Plan.Get(ctx, &plan)
+ response.Diagnostics.Append(diags...)
+ if response.Diagnostics.HasError() {
+ return
+ }
+
+ input := plan.toPrivateLocation()
+
+ namespace := plan.SpaceID.ValueString()
+ result, err := kibanaClient.KibanaSynthetics.PrivateLocation.Create(input.PrivateLocationConfig, namespace)
+ if err != nil {
+ response.Diagnostics.AddError(fmt.Sprintf("Failed to create private location `%s`, namespace %s", input.Label, namespace), err.Error())
+ return
+ }
+
+ plan = toModelV0(*result)
+
+ diags = response.State.Set(ctx, plan)
+ response.Diagnostics.Append(diags...)
+ if response.Diagnostics.HasError() {
+ return
+ }
+}
diff --git a/internal/kibana/synthetics/private_location/delete.go b/internal/kibana/synthetics/private_location/delete.go
new file mode 100644
index 0000000000..1b7ecb5f1b
--- /dev/null
+++ b/internal/kibana/synthetics/private_location/delete.go
@@ -0,0 +1,35 @@
+package private_location
+
+import (
+ "context"
+ "fmt"
+ "github.com/hashicorp/terraform-plugin-framework/resource"
+ "github.com/hashicorp/terraform-plugin-log/tflog"
+)
+
+func (r *Resource) Delete(ctx context.Context, request resource.DeleteRequest, response *resource.DeleteResponse) {
+
+ tflog.Info(ctx, "Delete private location")
+
+ kibanaClient := r.getKibanaClient(response.Diagnostics)
+ if kibanaClient == nil {
+ return
+ }
+
+ var plan tfModelV0
+ diags := request.State.Get(ctx, &plan)
+ response.Diagnostics.Append(diags...)
+ if response.Diagnostics.HasError() {
+ return
+ }
+
+ id := plan.ID.ValueString()
+ namespace := plan.SpaceID.ValueString()
+ err := kibanaClient.KibanaSynthetics.PrivateLocation.Delete(id, namespace)
+
+ if err != nil {
+ response.Diagnostics.AddError(fmt.Sprintf("Failed to delete private location `%s`, namespace %s", id, namespace), err.Error())
+ return
+ }
+
+}
diff --git a/internal/kibana/synthetics/private_location/read.go b/internal/kibana/synthetics/private_location/read.go
new file mode 100644
index 0000000000..dedd7a8b74
--- /dev/null
+++ b/internal/kibana/synthetics/private_location/read.go
@@ -0,0 +1,50 @@
+package private_location
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "github.com/disaster37/go-kibana-rest/v8/kbapi"
+ "github.com/hashicorp/terraform-plugin-framework/resource"
+ "github.com/hashicorp/terraform-plugin-log/tflog"
+)
+
+func (r *Resource) Read(ctx context.Context, request resource.ReadRequest, response *resource.ReadResponse) {
+
+ tflog.Info(ctx, "Read private location")
+
+ kibanaClient := r.getKibanaClient(response.Diagnostics)
+ if kibanaClient == nil {
+ return
+ }
+
+ var state tfModelV0
+ diags := request.State.Get(ctx, &state)
+ response.Diagnostics.Append(diags...)
+ if response.Diagnostics.HasError() {
+ return
+ }
+
+ label := state.Label.ValueString()
+ namespace := state.SpaceID.ValueString()
+ result, err := kibanaClient.KibanaSynthetics.PrivateLocation.Get(label, namespace)
+ if err != nil {
+ var apiError *kbapi.APIError
+ if errors.As(err, &apiError) && apiError.Code == 404 {
+ response.State.RemoveResource(ctx)
+ return
+ }
+
+ response.Diagnostics.AddError(fmt.Sprintf("Failed to get private location `%s`, namespace %s", label, namespace), err.Error())
+ return
+ }
+
+ state = toModelV0(*result)
+
+ // Set refreshed state
+ diags = response.State.Set(ctx, &state)
+ response.Diagnostics.Append(diags...)
+ if response.Diagnostics.HasError() {
+ return
+ }
+}
diff --git a/internal/kibana/synthetics/private_location/resource.go b/internal/kibana/synthetics/private_location/resource.go
new file mode 100644
index 0000000000..0da381cdef
--- /dev/null
+++ b/internal/kibana/synthetics/private_location/resource.go
@@ -0,0 +1,63 @@
+package private_location
+
+import (
+ "context"
+ "github.com/disaster37/go-kibana-rest/v8"
+ "github.com/elastic/terraform-provider-elasticstack/internal/clients"
+ "github.com/elastic/terraform-provider-elasticstack/internal/kibana/synthetics"
+ "github.com/hashicorp/terraform-plugin-framework/diag"
+ "github.com/hashicorp/terraform-plugin-framework/path"
+ "github.com/hashicorp/terraform-plugin-framework/resource"
+ "github.com/hashicorp/terraform-plugin-log/tflog"
+)
+
+const resourceName = synthetics.MetadataPrefix + "private_location"
+
+// Ensure provider defined types fully satisfy framework interfaces
+var _ resource.Resource = &Resource{}
+var _ resource.ResourceWithConfigure = &Resource{}
+var _ resource.ResourceWithImportState = &Resource{}
+
+type Resource struct {
+ client *clients.ApiClient
+}
+
+func (r *Resource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
+ resp.Schema = privateLocationSchema()
+}
+
+func (r *Resource) ImportState(ctx context.Context, request resource.ImportStateRequest, response *resource.ImportStateResponse) {
+ tflog.Info(ctx, "Import private location")
+ resource.ImportStatePassthroughID(ctx, path.Root("label"), request, response)
+}
+
+func (r *Resource) Configure(ctx context.Context, request resource.ConfigureRequest, response *resource.ConfigureResponse) {
+ client, diags := clients.ConvertProviderData(request.ProviderData)
+ response.Diagnostics.Append(diags...)
+ r.client = client
+}
+
+func (r *Resource) Metadata(ctx context.Context, request resource.MetadataRequest, response *resource.MetadataResponse) {
+ response.TypeName = request.ProviderTypeName + resourceName
+}
+
+func (r *Resource) Update(ctx context.Context, _ resource.UpdateRequest, response *resource.UpdateResponse) {
+ tflog.Warn(ctx, "Update isn't supported for elasticstack_"+resourceName)
+ response.Diagnostics.AddError(
+ "synthetics private location update not supported",
+ "Synthetics private location could only be replaced. Please, note, that only unused locations could be deleted.",
+ )
+}
+
+func (r *Resource) getKibanaClient(dg diag.Diagnostics) *kibana.Client {
+ if !r.resourceReady(&dg) {
+ return nil
+ }
+
+ kibanaClient, err := r.client.GetKibanaClient()
+ if err != nil {
+ dg.AddError("unable to get kibana client", err.Error())
+ return nil
+ }
+ return kibanaClient
+}
diff --git a/internal/kibana/synthetics/private_location/schema.go b/internal/kibana/synthetics/private_location/schema.go
new file mode 100644
index 0000000000..9b7316a5a5
--- /dev/null
+++ b/internal/kibana/synthetics/private_location/schema.go
@@ -0,0 +1,124 @@
+package private_location
+
+import (
+ "github.com/disaster37/go-kibana-rest/v8/kbapi"
+ "github.com/elastic/terraform-provider-elasticstack/internal/kibana/synthetics"
+ "github.com/hashicorp/terraform-plugin-framework/diag"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+)
+
+type tfModelV0 struct {
+ ID types.String `tfsdk:"id"`
+ Label types.String `tfsdk:"label"`
+ SpaceID types.String `tfsdk:"space_id"`
+ AgentPolicyId types.String `tfsdk:"agent_policy_id"`
+ Tags []types.String `tfsdk:"tags"` //> string
+ Geo *synthetics.TFGeoConfigV0 `tfsdk:"geo"`
+}
+
+func privateLocationSchema() schema.Schema {
+ return schema.Schema{
+ MarkdownDescription: "Synthetics private location config, see https://www.elastic.co/guide/en/kibana/current/create-private-location-api.html for more details",
+ Attributes: map[string]schema.Attribute{
+ "id": schema.StringAttribute{
+ Computed: true,
+ MarkdownDescription: "Generated id for the private location. For monitor setup please use private location label.",
+ PlanModifiers: []planmodifier.String{
+ stringplanmodifier.UseStateForUnknown(),
+ stringplanmodifier.RequiresReplace(),
+ },
+ },
+ "space_id": schema.StringAttribute{
+ MarkdownDescription: "An identifier for the space. If space_id is not provided, the default space is used.",
+ Optional: true,
+ PlanModifiers: []planmodifier.String{
+ stringplanmodifier.UseStateForUnknown(),
+ stringplanmodifier.RequiresReplace(),
+ },
+ },
+ "label": schema.StringAttribute{
+ Optional: false,
+ Required: true,
+ MarkdownDescription: "A label for the private location, used as unique identifier",
+ PlanModifiers: []planmodifier.String{
+ stringplanmodifier.UseStateForUnknown(),
+ stringplanmodifier.RequiresReplace(),
+ },
+ },
+ "agent_policy_id": schema.StringAttribute{
+ Optional: false,
+ Required: true,
+ MarkdownDescription: "The ID of the agent policy associated with the private location. To create a private location for synthetics monitor you need to create an agent policy in fleet and use its agentPolicyId",
+ PlanModifiers: []planmodifier.String{
+ stringplanmodifier.UseStateForUnknown(),
+ stringplanmodifier.RequiresReplace(),
+ },
+ },
+ "tags": schema.ListAttribute{
+ ElementType: types.StringType,
+ Optional: true,
+ MarkdownDescription: "An array of tags to categorize the private location.",
+ PlanModifiers: []planmodifier.List{
+ listplanmodifier.UseStateForUnknown(),
+ listplanmodifier.RequiresReplace(),
+ },
+ },
+ "geo": synthetics.GeoConfigSchema(),
+ },
+ }
+}
+
+func (r *Resource) resourceReady(dg *diag.Diagnostics) bool {
+ if r.client == nil {
+ dg.AddError(
+ "Unconfigured Client",
+ "Expected configured client. Please report this issue to the provider developers.",
+ )
+
+ return false
+ }
+ return true
+}
+
+func (m *tfModelV0) toPrivateLocation() kbapi.PrivateLocation {
+ var geoConfig *kbapi.SyntheticGeoConfig
+ if m.Geo != nil {
+ geoConfig = m.Geo.ToSyntheticGeoConfig()
+ }
+
+ var tags []string
+ for _, tag := range m.Tags {
+ tags = append(tags, tag.ValueString())
+ }
+ pLoc := kbapi.PrivateLocationConfig{
+ Label: m.Label.ValueString(),
+ AgentPolicyId: m.AgentPolicyId.ValueString(),
+ Tags: tags,
+ Geo: geoConfig,
+ }
+
+ return kbapi.PrivateLocation{
+ Id: m.ID.ValueString(),
+ Namespace: m.SpaceID.ValueString(),
+ PrivateLocationConfig: pLoc,
+ }
+}
+
+func toModelV0(pLoc kbapi.PrivateLocation) tfModelV0 {
+ var tags []types.String
+ for _, tag := range pLoc.Tags {
+ tags = append(tags, types.StringValue(tag))
+ }
+ return tfModelV0{
+ ID: types.StringValue(pLoc.Id),
+ Label: types.StringValue(pLoc.Label),
+ SpaceID: types.StringValue(pLoc.Namespace),
+ AgentPolicyId: types.StringValue(pLoc.AgentPolicyId),
+ Tags: tags,
+ Geo: synthetics.FromSyntheticGeoConfig(pLoc.Geo),
+ }
+}
diff --git a/internal/kibana/synthetics/private_location/schema_test.go b/internal/kibana/synthetics/private_location/schema_test.go
new file mode 100644
index 0000000000..c22c6a8469
--- /dev/null
+++ b/internal/kibana/synthetics/private_location/schema_test.go
@@ -0,0 +1,78 @@
+package private_location
+
+import (
+ "testing"
+
+ "github.com/disaster37/go-kibana-rest/v8/kbapi"
+ "github.com/stretchr/testify/assert"
+)
+
+func Test_roundtrip(t *testing.T) {
+ tests := []struct {
+ name string
+ id string
+ ns string
+ plc kbapi.PrivateLocationConfig
+ }{
+ {
+ name: "only required fields",
+ id: "id-1",
+ ns: "ns-1",
+ plc: kbapi.PrivateLocationConfig{
+ Label: "label-1",
+ AgentPolicyId: "agent-policy-id-1",
+ },
+ },
+ {
+ name: "all fields",
+ id: "id-2",
+ ns: "ns-2",
+ plc: kbapi.PrivateLocationConfig{
+ Label: "label-2",
+ AgentPolicyId: "agent-policy-id-2",
+ Tags: []string{"tag-1", "tag-2", "tag-3"},
+ Geo: &kbapi.SyntheticGeoConfig{
+ Lat: 43.2,
+ Lon: 23.1,
+ },
+ },
+ },
+ {
+ name: "only tags",
+ id: "id-3",
+ ns: "ns-3",
+ plc: kbapi.PrivateLocationConfig{
+ Label: "label-3",
+ AgentPolicyId: "agent-policy-id-3",
+ Tags: []string{"tag-1", "tag-2", "tag-3"},
+ Geo: nil,
+ },
+ },
+ {
+ name: "only geo",
+ id: "id-4",
+ ns: "ns-4",
+ plc: kbapi.PrivateLocationConfig{
+ Label: "label-4",
+ AgentPolicyId: "agent-policy-id-4",
+ Geo: &kbapi.SyntheticGeoConfig{
+ Lat: 43.2,
+ Lon: 23.1,
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ plc := tt.plc
+ input := kbapi.PrivateLocation{
+ Id: tt.id,
+ Namespace: tt.ns,
+ PrivateLocationConfig: plc,
+ }
+ modelV0 := toModelV0(input)
+ actual := modelV0.toPrivateLocation()
+ assert.Equal(t, input, actual)
+ })
+ }
+}
diff --git a/internal/kibana/synthetics/schema.go b/internal/kibana/synthetics/schema.go
new file mode 100644
index 0000000000..91f29aaf6b
--- /dev/null
+++ b/internal/kibana/synthetics/schema.go
@@ -0,0 +1,52 @@
+package synthetics
+
+import (
+ "github.com/disaster37/go-kibana-rest/v8/kbapi"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+)
+
+const (
+ MetadataPrefix = "_kibana_synthetics_"
+)
+
+func GeoConfigSchema() schema.Attribute {
+ return schema.SingleNestedAttribute{
+ Optional: true,
+ Description: "Geographic coordinates (WGS84) for the location",
+ Attributes: map[string]schema.Attribute{
+ "lat": schema.Float64Attribute{
+ Optional: false,
+ Required: true,
+ MarkdownDescription: "The latitude of the location.",
+ },
+ "lon": schema.Float64Attribute{
+ Optional: false,
+ Required: true,
+ MarkdownDescription: "The longitude of the location.",
+ },
+ },
+ }
+}
+
+type TFGeoConfigV0 struct {
+ Lat types.Float64 `tfsdk:"lat"`
+ Lon types.Float64 `tfsdk:"lon"`
+}
+
+func (m *TFGeoConfigV0) ToSyntheticGeoConfig() *kbapi.SyntheticGeoConfig {
+ return &kbapi.SyntheticGeoConfig{
+ Lat: m.Lat.ValueFloat64(),
+ Lon: m.Lon.ValueFloat64(),
+ }
+}
+
+func FromSyntheticGeoConfig(v *kbapi.SyntheticGeoConfig) *TFGeoConfigV0 {
+ if v == nil {
+ return nil
+ }
+ return &TFGeoConfigV0{
+ Lat: types.Float64Value(v.Lat),
+ Lon: types.Float64Value(v.Lon),
+ }
+}
diff --git a/internal/utils/http_log.go b/internal/utils/http_log.go
index 512ab1da2e..7135a87697 100644
--- a/internal/utils/http_log.go
+++ b/internal/utils/http_log.go
@@ -33,6 +33,7 @@ func NewDebugTransport(name string, transport http.RoundTripper) *debugRoundTrip
}
func (d *debugRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) {
+
ctx := r.Context()
reqData, err := httputil.DumpRequestOut(r, true)
if err == nil {
diff --git a/libs/go-kibana-rest/kbapi/api.kibana_synthetics.go b/libs/go-kibana-rest/kbapi/api.kibana_synthetics.go
index 6548130689..f141d8bed2 100644
--- a/libs/go-kibana-rest/kbapi/api.kibana_synthetics.go
+++ b/libs/go-kibana-rest/kbapi/api.kibana_synthetics.go
@@ -12,23 +12,7 @@ const (
basePathKibanaSynthetics = "/api/synthetics"
privateLocationsSuffix = "/private_locations"
monitorsSuffix = "/monitors"
-)
-
-type MonitorID string
-type MonitorType string
-type MonitorLocation string
-type MonitorSchedule int
-type HttpMonitorMode string
-
-type KibanaError struct {
- Code int `json:"statusCode,omitempty"`
- Error string `json:"error,omitempty"`
- Message string `json:"message,omitempty"`
-}
-
-type JsonObject map[string]interface{}
-const (
Http MonitorType = "http"
Tcp MonitorType = "tcp"
Icmp MonitorType = "icmp"
@@ -61,6 +45,20 @@ const (
ModeAny = "any"
)
+type KibanaError struct {
+ Code int `json:"statusCode,omitempty"`
+ Error string `json:"error,omitempty"`
+ Message string `json:"message,omitempty"`
+}
+
+type MonitorID string
+type MonitorType string
+type MonitorLocation string
+type MonitorSchedule int
+type HttpMonitorMode string
+
+type JsonObject map[string]interface{}
+
type KibanaSyntheticsMonitorAPI struct {
Add KibanaSyntheticsMonitorAdd
Delete KibanaSyntheticsMonitorDelete
@@ -130,6 +128,24 @@ type MonitorLocationConfig struct {
IsServiceManaged bool `json:"isServiceManaged"`
}
+type PrivateLocationConfig struct {
+ Label string `json:"label"`
+ AgentPolicyId string `json:"agentPolicyId"`
+ Tags []string `json:"tags,omitempty"`
+ Geo *SyntheticGeoConfig `json:"geo,omitempty"`
+}
+
+type PrivateLocation struct {
+ Id string `json:"id"`
+ Namespace string `json:"namespace,omitempty"`
+ PrivateLocationConfig
+}
+
+type MonitorDeleteStatus struct {
+ Id MonitorID `json:"id"`
+ Deleted bool `json:"deleted"`
+}
+
type SyntheticsMonitor struct {
Name string `json:"name"`
Type MonitorType `json:"type"`
@@ -142,9 +158,12 @@ type SyntheticsMonitor struct {
Enabled *bool `json:"enabled,omitempty"`
Alert *MonitorAlertConfig `json:"alert,omitempty"`
Schedule *MonitorScheduleConfig `json:"schedule,omitempty"`
+ Tags []string `json:"tags,omitempty"`
+ APMServiceName string `json:"service.name,omitempty"`
Timeout json.Number `json:"timeout,omitempty"`
Locations []MonitorLocationConfig `json:"locations,omitempty"`
Origin string `json:"origin,omitempty"`
+ Params JsonObject `json:"params,omitempty"`
MaxAttempts int `json:"max_attempts"`
MaxRedirects string `json:"max_redirects"`
ResponseIncludeBody string `json:"response.include_body"`
@@ -162,23 +181,6 @@ type SyntheticsMonitor struct {
} `json:"__ui,omitempty"`
}
-type PrivateLocationConfig struct {
- Label string `json:"label"`
- AgentPolicyId string `json:"agentPolicyId"`
- Tags []string `json:"tags,omitempty"`
- Geo *SyntheticGeoConfig `json:"geo,omitempty"`
-}
-
-type PrivateLocation struct {
- Id string `json:"id"`
- PrivateLocationConfig
-}
-
-type MonitorDeleteStatus struct {
- Id MonitorID `json:"id"`
- Deleted bool `json:"deleted"`
-}
-
type KibanaSyntheticsMonitorAdd func(config SyntheticsMonitorConfig, fields HTTPMonitorFields, namespace string) (*SyntheticsMonitor, error)
type KibanaSyntheticsMonitorUpdate func(id MonitorID, config SyntheticsMonitorConfig, fields HTTPMonitorFields, namespace string) (*SyntheticsMonitor, error)
@@ -196,6 +198,13 @@ type KibanaSyntheticsPrivateLocationDelete func(id string, namespace string) err
func newKibanaSyntheticsPrivateLocationGetFunc(c *resty.Client) KibanaSyntheticsPrivateLocationGet {
return func(idOrLabel string, namespace string) (*PrivateLocation, error) {
+ if idOrLabel == "" {
+ return nil, APIError{
+ Code: 404,
+ Message: "Private location id or label is empty",
+ }
+ }
+
path := basePathWithId(namespace, privateLocationsSuffix, idOrLabel)
log.Debugf("URL to get private locations: %s", path)
resp, err := c.R().Get(path)
diff --git a/libs/go-kibana-rest/kbapi/api.kibana_synthetics_test.go b/libs/go-kibana-rest/kbapi/api.kibana_synthetics_test.go
index 524d583647..b3341653d8 100644
--- a/libs/go-kibana-rest/kbapi/api.kibana_synthetics_test.go
+++ b/libs/go-kibana-rest/kbapi/api.kibana_synthetics_test.go
@@ -119,6 +119,7 @@ func (s *KBAPITestSuite) TestKibanaSyntheticsMonitorAPI() {
Namespace: space,
Params: map[string]interface{}{
"param1": "some-params",
+ "my_url": "http://localhost:8080",
},
RetestOnFailure: f,
},
@@ -179,6 +180,7 @@ func (s *KBAPITestSuite) TestKibanaSyntheticsMonitorAPI() {
monitor, err := syntheticsAPI.Monitor.Add(config, fields, space)
assert.NoError(s.T(), err)
assert.NotNil(s.T(), monitor)
+ monitor.Params = nil //kibana API doesn't return params for GET request
get, err := syntheticsAPI.Monitor.Get(monitor.Id, space)
assert.NoError(s.T(), err)
@@ -190,6 +192,8 @@ func (s *KBAPITestSuite) TestKibanaSyntheticsMonitorAPI() {
update, err := syntheticsAPI.Monitor.Update(monitor.Id, tc.update.config, tc.update.fields, space)
assert.NoError(s.T(), err)
+ assert.NotNil(s.T(), update)
+ update.Params = nil //kibana API doesn't return params for GET request
get, err = syntheticsAPI.Monitor.Get(monitor.ConfigId, space)
assert.NoError(s.T(), err)
@@ -254,3 +258,22 @@ func (s *KBAPITestSuite) TestKibanaSyntheticsPrivateLocationAPI() {
})
}
}
+
+func (s *KBAPITestSuite) TestKibanaSyntheticsPrivateLocationNotFound() {
+ for _, n := range namespaces {
+ testUuid := uuid.New().String()
+ space := n
+ pAPI := s.API.KibanaSynthetics.PrivateLocation
+
+ ids := []string{"", "not-found", testUuid}
+
+ for _, id := range ids {
+ s.Run(fmt.Sprintf("TestKibanaSyntheticsPrivateLocationNotFound - %s - %s", n, id), func() {
+ _, err := pAPI.Get(id, space)
+ assert.Error(s.T(), err)
+ assert.IsType(s.T(), APIError{}, err)
+ assert.Equal(s.T(), 404, err.(APIError).Code)
+ })
+ }
+ }
+}
diff --git a/provider/plugin_framework.go b/provider/plugin_framework.go
index a5a190c6a2..5a6b7f5f0b 100644
--- a/provider/plugin_framework.go
+++ b/provider/plugin_framework.go
@@ -7,6 +7,7 @@ import (
"github.com/elastic/terraform-provider-elasticstack/internal/clients/config"
"github.com/elastic/terraform-provider-elasticstack/internal/kibana/data_view"
"github.com/elastic/terraform-provider-elasticstack/internal/kibana/import_saved_objects"
+ "github.com/elastic/terraform-provider-elasticstack/internal/kibana/synthetics/private_location"
"github.com/elastic/terraform-provider-elasticstack/internal/schema"
"github.com/hashicorp/terraform-plugin-framework/datasource"
fwprovider "github.com/hashicorp/terraform-plugin-framework/provider"
@@ -14,6 +15,11 @@ import (
"github.com/hashicorp/terraform-plugin-framework/resource"
)
+// Ensure the implementation satisfies the expected interfaces.
+var (
+ _ fwprovider.Provider = &Provider{}
+)
+
type Provider struct {
version string
}
@@ -41,14 +47,14 @@ func (p *Provider) Schema(ctx context.Context, req fwprovider.SchemaRequest, res
}
func (p *Provider) Configure(ctx context.Context, req fwprovider.ConfigureRequest, res *fwprovider.ConfigureResponse) {
- var config config.ProviderConfiguration
+ var cfg config.ProviderConfiguration
- res.Diagnostics.Append(req.Config.Get(ctx, &config)...)
+ res.Diagnostics.Append(req.Config.Get(ctx, &cfg)...)
if res.Diagnostics.HasError() {
return
}
- client, diags := clients.NewApiClientFromFramework(ctx, config, p.version)
+ client, diags := clients.NewApiClientFromFramework(ctx, cfg, p.version)
res.Diagnostics.Append(diags...)
if res.Diagnostics.HasError() {
return
@@ -66,5 +72,6 @@ func (p *Provider) Resources(ctx context.Context) []func() resource.Resource {
return []func() resource.Resource{
func() resource.Resource { return &import_saved_objects.Resource{} },
func() resource.Resource { return &data_view.Resource{} },
+ func() resource.Resource { return &private_location.Resource{} },
}
}
diff --git a/templates/resources/kibana_synthetics_private_location.tmpl b/templates/resources/kibana_synthetics_private_location.tmpl
new file mode 100644
index 0000000000..694302668e
--- /dev/null
+++ b/templates/resources/kibana_synthetics_private_location.tmpl
@@ -0,0 +1,25 @@
+---
+subcategory: "Kibana"
+layout: ""
+page_title: "Elasticstack: elasticstack_kibana_synthetics_private_location Resource"
+description: |-
+ Creates or updates a Kibana synthetics private location.
+---
+
+# Resource: elasticstack_kibana_synthetics_private_location
+
+Creates or updates a Kibana synthetics private location.
+See [Monitor via a private agent](https://www.elastic.co/guide/en/observability/current/synthetics-private-location.html#monitor-via-private-agent)
+and [api docs](https://www.elastic.co/guide/en/kibana/current/create-private-location-api.html)
+
+## Example Usage
+
+{{ tffile "examples/resources/elasticstack_kibana_synthetics_private_location/resource.tf" }}
+
+{{ .SchemaMarkdown | trimspace }}
+
+## Import
+
+Import is supported using the following syntax:
+
+{{ codefile "shell" "examples/resources/elasticstack_kibana_synthetics_private_location/import.sh" }}
\ No newline at end of file