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