diff --git a/CHANGELOG.md b/CHANGELOG.md index 8190079cc..fd7ddd2f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ - Prevent a provider panic when an `elasticstack_elasticsearch_template` or `elasticstack_elasticsearch_component_template` includes an empty `template` (`template {}`) block. ([#598](https://github.com/elastic/terraform-provider-elasticstack/pull/598)) - Prevent `elasticstack_kibana_space` to attempt the space recreation if `initials` and `color` are not provided. ([#606](https://github.com/elastic/terraform-provider-elasticstack/pull/606)) +### Added + +- Added datasource for alerting connectors. ([#607](https://github.com/elastic/terraform-provider-elasticstack/pull/607)) + ## [0.11.2] - 2024-03-13 ### Fixed diff --git a/docs/data-sources/kibana_action_connector.md b/docs/data-sources/kibana_action_connector.md new file mode 100644 index 000000000..5513b5d70 --- /dev/null +++ b/docs/data-sources/kibana_action_connector.md @@ -0,0 +1,51 @@ +--- +subcategory: "Kibana" +layout: "" +page_title: "Elasticstack: elasticstack_kibana_action_connector Data Source" +description: |- + Retrieve a specific action connector role. See https://www.elastic.co/guide/en/kibana/current/get-all-connectors-api.html. +--- + +# Data Source: elasticstack_kibana_action_connector + +Use this data source to get information about an existing action connector. + +## Example Usage + +```terraform +provider "elasticstack" { + elasticsearch {} + kibana {} +} + +data "elasticstack_kibana_action_connector" "example" { + name = "myslackconnector" + space_id = "default" + connector_type_id = ".slack" +} + +output "connector_id" { + value = data.elasticstack_kibana_action_connector.example.connector_id +} +``` + + +## Schema + +### Required + +- `name` (String) The name of the connector. While this name does not have to be unique, a distinctive name can help you identify a connector. + +### Optional + +- `connector_type_id` (String) The ID of the connector type, e.g. `.index`. +- `space_id` (String) An identifier for the space. If space_id is not provided, the default space is used. + +### Read-Only + +- `config` (String) The configuration for the connector. Configuration properties vary depending on the connector type. +- `connector_id` (String) A UUID v1 or v4 randomly generated ID. +- `id` (String) The ID of this resource. +- `is_deprecated` (Boolean) Indicates whether the connector type is deprecated. +- `is_missing_secrets` (Boolean) Indicates whether secrets are missing for the connector. +- `is_preconfigured` (Boolean) Indicates whether it is a preconfigured connector. diff --git a/examples/data-sources/elasticstack_kibana_action_connector/data-source.tf b/examples/data-sources/elasticstack_kibana_action_connector/data-source.tf new file mode 100644 index 000000000..000ebc5ad --- /dev/null +++ b/examples/data-sources/elasticstack_kibana_action_connector/data-source.tf @@ -0,0 +1,14 @@ +provider "elasticstack" { + elasticsearch {} + kibana {} +} + +data "elasticstack_kibana_action_connector" "example" { + name = "myslackconnector" + space_id = "default" + connector_type_id = ".slack" +} + +output "connector_id" { + value = data.elasticstack_kibana_action_connector.example.connector_id +} diff --git a/internal/clients/kibana/connector.go b/internal/clients/kibana/connector.go index 6ee9b104f..027dc5c19 100644 --- a/internal/clients/kibana/connector.go +++ b/internal/clients/kibana/connector.go @@ -11,6 +11,7 @@ import ( "github.com/elastic/terraform-provider-elasticstack/internal/clients" "github.com/elastic/terraform-provider-elasticstack/internal/models" "github.com/elastic/terraform-provider-elasticstack/internal/utils" + "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" ) @@ -141,6 +142,69 @@ func GetConnector(ctx context.Context, apiClient *clients.ApiClient, connectorID return connector, nil } +func SearchConnectors(ctx context.Context, apiClient *clients.ApiClient, connectorName, spaceID, connectorTypeID string) ([]*models.KibanaActionConnector, diag.Diagnostics) { + client, err := apiClient.GetKibanaConnectorsClient(ctx) + if err != nil { + return nil, diag.FromErr(err) + } + + httpResp, err := client.GetConnectors(ctx, spaceID) + + if err != nil { + return nil, diag.Errorf("unable to get connectors: [%v]", err) + } + + defer httpResp.Body.Close() + + resp, err := connectors.ParseGetConnectorsResponse(httpResp) + if err != nil { + return nil, diag.Errorf("unable to parse connectors get response: [%v]", err) + } + + if resp.JSON401 != nil { + return nil, diag.Errorf("%s: %s", *resp.JSON401.Error, *resp.JSON401.Message) + } + + if resp.JSON200 == nil { + return nil, diag.Errorf("%s: %s", resp.Status(), string(resp.Body)) + } + + foundConnectors := []*models.KibanaActionConnector{} + for _, connector := range *resp.JSON200 { + if connector.Name != connectorName { + continue + } + + if connectorTypeID != "" && string(connector.ConnectorTypeId) != connectorTypeID { + continue + } + + //this marshaling and unmarshaling business allows us to create a type with unexported fields. + bytes, err := json.Marshal(connector) + if err != nil { + return nil, diag.Errorf("cannot marshal connector: %v", err) + } + + var respProps connectors.ConnectorResponseProperties + err = json.Unmarshal(bytes, &respProps) + if err != nil { + return nil, diag.Errorf("cannot unmarshal connector: %v", err) + } + + c, err := connectorResponseToModel(spaceID, respProps) + if err != nil { + return nil, diag.Errorf("unable to convert response to model: %v", err) + } + + foundConnectors = append(foundConnectors, c) + } + if len(foundConnectors) == 0 { + tflog.Debug(ctx, fmt.Sprintf("no connectors found with name [%s/%s] and type [%s]", spaceID, connectorName, connectorTypeID)) + } + + return foundConnectors, nil +} + func DeleteConnector(ctx context.Context, apiClient *clients.ApiClient, connectorID string, spaceID string) diag.Diagnostics { client, err := apiClient.GetKibanaConnectorsClient(ctx) if err != nil { diff --git a/internal/clients/kibana/connector_test.go b/internal/clients/kibana/connector_test.go index acaec9f9a..566611cab 100644 --- a/internal/clients/kibana/connector_test.go +++ b/internal/clients/kibana/connector_test.go @@ -1,11 +1,16 @@ package kibana import ( + "context" "encoding/json" "fmt" + "net/http" + "net/http/httptest" + "os" "testing" "github.com/elastic/terraform-provider-elasticstack/generated/connectors" + "github.com/elastic/terraform-provider-elasticstack/internal/clients" "github.com/elastic/terraform-provider-elasticstack/internal/models" "github.com/stretchr/testify/require" ) @@ -130,3 +135,119 @@ func Test_connectorResponseToModel(t *testing.T) { }) } } + +func TestGetConnectorByName(t *testing.T) { + const getConnectorsResponse = `[ + { + "id": "c55b6eb0-6bad-11eb-9f3b-611eebc6c3ad", + "connector_type_id": ".index", + "name": "my-connector", + "config": { + "index": "test-index", + "refresh": false, + "executionTimeField": null + }, + "is_preconfigured": false, + "is_deprecated": false, + "is_missing_secrets": false, + "referenced_by_count": 3 + }, + { + "id": "d55b6eb0-6bad-11eb-9f3b-611eebc6c3ad", + "connector_type_id": ".index", + "name": "doubledup-connector", + "config": { + "index": "test-index", + "refresh": false, + "executionTimeField": null + }, + "is_preconfigured": false, + "is_deprecated": false, + "is_missing_secrets": false, + "referenced_by_count": 3 + }, + { + "id": "855b6eb0-6bad-11eb-9f3b-611eebc6c3ad", + "connector_type_id": ".index", + "name": "doubledup-connector", + "config": { + "index": "test-index", + "refresh": false, + "executionTimeField": null + }, + "is_preconfigured": false, + "is_deprecated": false, + "is_missing_secrets": false, + "referenced_by_count": 0 + } + ]` + + const emptyConnectorsResponse = `[]` + + var requests []*http.Request + var mockResponses []string + var httpStatus int + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + requests = append(requests, req) + + if len(mockResponses) > 0 { + r := []byte(mockResponses[0]) + rw.Header().Add("X-Elastic-Product", "Elasticsearch") + rw.Header().Add("Content-Type", "application/json") + rw.WriteHeader(httpStatus) + _, err := rw.Write(r) + require.NoError(t, err) + mockResponses = mockResponses[1:] + } else { + t.Fatalf("Unexpected request: %s %s", req.Method, req.URL.Path) + } + })) + defer server.Close() + + httpStatus = http.StatusOK + mockResponses = append(mockResponses, getConnectorsResponse) + + err := os.Setenv("ELASTICSEARCH_URL", server.URL) + require.NoError(t, err) + err = os.Setenv("KIBANA_ENDPOINT", server.URL) + require.NoError(t, err) + + apiClient, err := clients.NewAcceptanceTestingClient() + require.NoError(t, err) + + connector, diags := SearchConnectors(context.Background(), apiClient, "my-connector", "default", "") + require.Nil(t, diags) + require.NotNil(t, connector) + + mockResponses = append(mockResponses, getConnectorsResponse) + failConnector, diags := SearchConnectors(context.Background(), apiClient, "failwhale", "default", "") + require.Nil(t, diags) + require.Empty(t, failConnector) + + mockResponses = append(mockResponses, getConnectorsResponse) + dupConnector, diags := SearchConnectors(context.Background(), apiClient, "doubledup-connector", "default", "") + require.Nil(t, diags) + require.Len(t, dupConnector, 2) + + mockResponses = append(mockResponses, getConnectorsResponse) + wrongConnectorType, diags := SearchConnectors(context.Background(), apiClient, "my-connector", "default", ".slack") + require.Nil(t, diags) + require.Empty(t, wrongConnectorType) + + mockResponses = append(mockResponses, getConnectorsResponse) + successConnector, diags := SearchConnectors(context.Background(), apiClient, "my-connector", "default", ".index") + require.Nil(t, diags) + require.Len(t, successConnector, 1) + + mockResponses = append(mockResponses, emptyConnectorsResponse) + emptyConnector, diags := SearchConnectors(context.Background(), apiClient, "my-connector", "default", "") + require.Nil(t, diags) + require.Empty(t, emptyConnector) + + httpStatus = http.StatusBadGateway + mockResponses = append(mockResponses, emptyConnectorsResponse) + fail, diags := SearchConnectors(context.Background(), apiClient, "my-connector", "default", "") + require.NotNil(t, diags) + require.Nil(t, fail) + +} diff --git a/internal/kibana/connector.go b/internal/kibana/connector.go index 1a57b7fb7..e44227fd1 100644 --- a/internal/kibana/connector.go +++ b/internal/kibana/connector.go @@ -13,7 +13,7 @@ import ( ) func ResourceActionConnector() *schema.Resource { - apikeySchema := map[string]*schema.Schema{ + var connectorSchema = map[string]*schema.Schema{ "connector_id": { Description: "A UUID v1 or v4 to use instead of a randomly generated ID.", Type: schema.TypeString, @@ -69,7 +69,6 @@ func ResourceActionConnector() *schema.Resource { Computed: true, }, } - return &schema.Resource{ Description: "Creates a Kibana action connector. See https://www.elastic.co/guide/en/kibana/current/action-types.html", @@ -83,7 +82,7 @@ func ResourceActionConnector() *schema.Resource { StateContext: schema.ImportStatePassthroughContext, }, - Schema: apikeySchema, + Schema: connectorSchema, } } diff --git a/internal/kibana/connector_data_source.go b/internal/kibana/connector_data_source.go new file mode 100644 index 000000000..e901c8bf3 --- /dev/null +++ b/internal/kibana/connector_data_source.go @@ -0,0 +1,90 @@ +package kibana + +import ( + "context" + + "github.com/elastic/terraform-provider-elasticstack/internal/clients" + "github.com/elastic/terraform-provider-elasticstack/internal/clients/kibana" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func DataSourceConnector() *schema.Resource { + var connectorSchema = map[string]*schema.Schema{ + "connector_id": { + Description: "A UUID v1 or v4 randomly generated ID.", + Type: schema.TypeString, + Computed: true, + }, + "space_id": { + Description: "An identifier for the space. If space_id is not provided, the default space is used.", + Type: schema.TypeString, + Optional: true, + Default: "default", + }, + "name": { + Description: "The name of the connector. While this name does not have to be unique, a distinctive name can help you identify a connector.", + Type: schema.TypeString, + Required: true, + }, + "connector_type_id": { + Description: "The ID of the connector type, e.g. `.index`.", + Type: schema.TypeString, + Optional: true, + }, + "config": { + Description: "The configuration for the connector. Configuration properties vary depending on the connector type.", + Type: schema.TypeString, + Computed: true, + }, + "is_deprecated": { + Description: "Indicates whether the connector type is deprecated.", + Type: schema.TypeBool, + Computed: true, + }, + "is_missing_secrets": { + Description: "Indicates whether secrets are missing for the connector.", + Type: schema.TypeBool, + Computed: true, + }, + "is_preconfigured": { + Description: "Indicates whether it is a preconfigured connector.", + Type: schema.TypeBool, + Computed: true, + }, + } + + return &schema.Resource{ + Description: "Search for a connector by name, space id, and type. Note, that this data source will fail if more than one connector shares the same name.", + ReadContext: datasourceConnectorRead, + Schema: connectorSchema, + } +} + +func datasourceConnectorRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client, diags := clients.NewApiClientFromSDKResource(d, meta) + if diags.HasError() { + return diags + } + connectorName := d.Get("name").(string) + spaceId := d.Get("space_id").(string) + connectorType := d.Get("connector_type_id").(string) + + foundConnectors, diags := kibana.SearchConnectors(ctx, client, connectorName, spaceId, connectorType) + if diags.HasError() { + return diags + } + + if len(foundConnectors) == 0 { + diag.Errorf("error while creating elasticstack_kibana_action_connector datasource: connector with name [%s/%s] and type [%s] not found", spaceId, connectorName, connectorType) + } + + if len(foundConnectors) > 1 { + return diag.Errorf("error while creating elasticstack_kibana_action_connector datasource: multiple connectors found with name [%s/%s] and type [%s]", spaceId, connectorName, connectorType) + } + + compositeID := &clients.CompositeId{ClusterId: spaceId, ResourceId: foundConnectors[0].ConnectorID} + d.SetId(compositeID.String()) + + return flattenActionConnector(foundConnectors[0], d) +} diff --git a/internal/kibana/connector_data_source_test.go b/internal/kibana/connector_data_source_test.go new file mode 100644 index 000000000..4b24e866f --- /dev/null +++ b/internal/kibana/connector_data_source_test.go @@ -0,0 +1,45 @@ +package kibana_test + +import ( + "testing" + + "github.com/elastic/terraform-provider-elasticstack/internal/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +func TestAccDataSourceKibanaConnector(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ProtoV6ProviderFactories: acctest.Providers, + Steps: []resource.TestStep{ + { + Config: testAccDataSourceConnector, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("data.elasticstack_kibana_action_connector.myconnector", "name", "myconnector"), + resource.TestCheckResourceAttr("data.elasticstack_kibana_action_connector.myconnector", "space_id", "default"), + resource.TestCheckResourceAttr("data.elasticstack_kibana_action_connector.myconnector", "connector_type_id", ".slack"), + resource.TestCheckResourceAttrSet("data.elasticstack_kibana_action_connector.myconnector", "connector_id"), + ), + }, + }, + }) +} + +const testAccDataSourceConnector = ` +provider "elasticstack" { + elasticsearch {} + kibana {} +} + +resource "elasticstack_kibana_action_connector" "slack" { + name = "myconnector" + connector_type_id = ".slack" + secrets = jsonencode({ + webhookUrl = "https://internet.com" + }) + } + +data "elasticstack_kibana_action_connector" "myconnector" { + name = elasticstack_kibana_action_connector.slack.name +} +` diff --git a/provider/provider.go b/provider/provider.go index ac41b9cf4..18145367c 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -79,7 +79,8 @@ func New(version string) *schema.Provider { "elasticstack_elasticsearch_info": cluster.DataSourceClusterInfo(), "elasticstack_elasticsearch_enrich_policy": enrich.DataSourceEnrichPolicy(), - "elasticstack_kibana_security_role": kibana.DataSourceRole(), + "elasticstack_kibana_action_connector": kibana.DataSourceConnector(), + "elasticstack_kibana_security_role": kibana.DataSourceRole(), "elasticstack_fleet_enrollment_tokens": fleet.DataSourceEnrollmentTokens(), "elasticstack_fleet_integration": fleet.DataSourceIntegration(), diff --git a/templates/data-sources/kibana_action_connector.md.tmpl b/templates/data-sources/kibana_action_connector.md.tmpl new file mode 100644 index 000000000..ce1432540 --- /dev/null +++ b/templates/data-sources/kibana_action_connector.md.tmpl @@ -0,0 +1,17 @@ +--- +subcategory: "Kibana" +layout: "" +page_title: "Elasticstack: elasticstack_kibana_action_connector Data Source" +description: |- + Retrieve a specific action connector role. See https://www.elastic.co/guide/en/kibana/current/get-all-connectors-api.html. +--- + +# Data Source: elasticstack_kibana_action_connector + +Use this data source to get information about an existing action connector. + +## Example Usage + +{{ tffile "examples/data-sources/elasticstack_kibana_action_connector/data-source.tf" }} + +{{ .SchemaMarkdown | trimspace }}