diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b9d5ac0..accada98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## 0.6.0 + +ENHANCEMENTS: + +* **New Data Source**: `firehydrant_slack_channel` ([#147](https://github.com/firehydrant/terraform-provider-firehydrant/pull/147)). +* Improved "resource not found error" with more details of URL. + ## 0.5.0 * `firehydrant_team` now supports `slug` attribute ([#145](https://github.com/firehydrant/terraform-provider-firehydrant/pull/145)). diff --git a/docs/data-sources/slack_channel.md b/docs/data-sources/slack_channel.md new file mode 100644 index 00000000..34370033 --- /dev/null +++ b/docs/data-sources/slack_channel.md @@ -0,0 +1,39 @@ +--- +page_title: "FireHydrant Data Source: firehydrant_slack_channel" +--- + +# firehydrant_slack_channel Data Source + +Use this data source to pass Slack channel information to other resources. + +## Example Usage + +Basic usage: +```hcl +data "firehydrant_slack_channel" "team_rocket" { + slack_channel_id = "C1234567890" +} + +resource "firehydrant_escalation_policy" "team_rocket" { + # ... + step { + timeout = "PT5M" + targets { + type = "SlackChannel" + id = data.firehydrant_slack_channel.team_rocket.id + } + } +} +``` + +## Argument Reference + +The following arguments are supported: + +* `slack_channel_id` - (Required) Slack's channel ID. + +## Attributes Reference + +In addition to all arguments above, the following attributes are exported: + +* `id` - The FireHydrant ID for the given Slack channel. diff --git a/firehydrant/client.go b/firehydrant/client.go index a48a799e..a3d3b739 100644 --- a/firehydrant/client.go +++ b/firehydrant/client.go @@ -26,7 +26,8 @@ func checkResponseStatusCode(response *http.Response, apiError *APIError) error case code >= 200 && code <= 299: return nil case code == 404: - return ErrorNotFound + req := response.Request + return fmt.Errorf("%w: %s '%s'", ErrorNotFound, req.Method, req.URL.String()) case code == 401: return fmt.Errorf("%s\n%s", ErrorUnauthorized, apiError) default: @@ -63,6 +64,7 @@ type Client interface { Severities() SeveritiesClient TaskLists() TaskListsClient Teams() TeamsClient + SlackChannels() SlackChannelsClient // Users GetUsers(ctx context.Context, params GetUserParams) (*UserResponse, error) @@ -206,6 +208,11 @@ func (c *APIClient) EscalationPolicies() EscalationPolicies { return &RESTEscalationPoliciesClient{client: c} } +// SlackChannels returns a SlackChannelsClient interface for interacting with slack channels in FireHydrant +func (c *APIClient) SlackChannels() SlackChannelsClient { + return &RESTSlackChannelsClient{client: c} +} + // GetUsers gets matching users in FireHydrant func (c *APIClient) GetUsers(ctx context.Context, params GetUserParams) (*UserResponse, error) { userResponse := &UserResponse{} diff --git a/firehydrant/slack_channels.go b/firehydrant/slack_channels.go new file mode 100644 index 00000000..502900a9 --- /dev/null +++ b/firehydrant/slack_channels.go @@ -0,0 +1,68 @@ +package firehydrant + +import ( + "context" + "fmt" + + "github.com/dghubble/sling" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +// SlackChannelsClient is an interface for interacting with Slack channels +type SlackChannelsClient interface { + Get(ctx context.Context, slackID string) (*SlackChannelResponse, error) +} + +// RESTSlackChannelsClient implements the SlackChannelClient interface +type RESTSlackChannelsClient struct { + client *APIClient +} + +var _ SlackChannelsClient = &RESTSlackChannelsClient{} + +func (c *RESTSlackChannelsClient) restClient() *sling.Sling { + return c.client.client() +} + +// Get retrieves a Slack channel from FireHydrant using Slack ID. This is useful for looking up +// a Slack channel's internal ID. +func (c *RESTSlackChannelsClient) Get(ctx context.Context, slackID string) (*SlackChannelResponse, error) { + channels := &SlackChannelsResponse{} + apiError := &APIError{} + response, err := c.restClient().Get("integrations/slack/channels?slack_channel_id="+slackID).Receive(channels, apiError) + if err != nil { + return nil, fmt.Errorf("could not get slack channel: %w", err) + } + + err = checkResponseStatusCode(response, apiError) + if err != nil { + return nil, err + } + + if channels.Channels == nil || len(channels.Channels) == 0 { + return nil, fmt.Errorf("no slack channel found with id '%s'", slackID) + } + if channelCount := len(channels.Channels); channelCount > 1 { + // "at least" because it may paginate. + tflog.Error(ctx, "found more than one Slack channel", map[string]interface{}{ + "id": slackID, + "found": channelCount, + }) + for _, channel := range channels.Channels { + tflog.Error(ctx, "found Slack channel", map[string]interface{}{ + "id": channel.ID, + "slack_channel_id": channel.SlackChannelID, + "name": channel.Name, + }) + } + return nil, fmt.Errorf("more than one Slack channel found: see Terraform logs for more information.") + } + + tflog.Info(ctx, "found Slack channel", map[string]interface{}{ + "id": channels.Channels[0].ID, + "slack_channel_id": channels.Channels[0].SlackChannelID, + "name": channels.Channels[0].Name, + }) + + return channels.Channels[0], nil +} diff --git a/firehydrant/slack_channels_test.go b/firehydrant/slack_channels_test.go new file mode 100644 index 00000000..58365bb2 --- /dev/null +++ b/firehydrant/slack_channels_test.go @@ -0,0 +1,88 @@ +package firehydrant + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "reflect" + "testing" +) + +func expectedSlackChannelResponse() *SlackChannelResponse { + return &SlackChannelResponse{ + ID: "00000000-0000-4000-8000-000000000000", + Name: "#team-rocket", + SlackChannelID: "C01010101Z", + } +} + +func expectedSlackChannelsResponseJSON() string { + return `{ + "data": [{"id":"00000000-0000-4000-8000-000000000000","name":"#team-rocket","slack_channel_id":"C01010101Z"}], + "pagination": {"count":1,"page":1,"items":1,"pages":1,"last":1,"prev":null,"next":null} +}` +} + +func slackChannelMockServer(path, query *string) *httptest.Server { + h := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + *path = req.URL.Path + *query = req.URL.Query().Get("slack_channel_id") + + if *query == "C01010101Z" { + w.Write([]byte(expectedSlackChannelsResponseJSON())) + } else { + w.WriteHeader(http.StatusNotFound) + } + }) + + ts := httptest.NewServer(h) + return ts +} + +func TestSlackChannelGet(t *testing.T) { + var requestPath, requestQuery string + ts := slackChannelMockServer(&requestPath, &requestQuery) + defer ts.Close() + + c, err := NewRestClient("test-token-very-authorized", WithBaseURL(ts.URL)) + if err != nil { + t.Fatalf("Received error initializing API client: %s", err.Error()) + return + } + res, err := c.SlackChannels().Get(context.Background(), "C01010101Z") + if err != nil { + t.Fatalf("error retrieving slack channel: %s", err.Error()) + } + + if expected := "/integrations/slack/channels"; expected != requestPath { + t.Fatalf("request path mismatch: expected '%s', got: '%s'", expected, requestPath) + } + if expected := "C01010101Z"; expected != requestQuery { + t.Fatalf("request query params mismatch: expected '%s', got: '%s'", expected, requestQuery) + } + + expectedResponse := expectedSlackChannelResponse() + if !reflect.DeepEqual(expectedResponse, res) { + t.Fatalf("response mismatch: expected '%+v', got: '%+v'", expectedResponse, res) + } +} + +func TestSlackChannelGetNotFound(t *testing.T) { + var requestPath, requestQuery string + ts := slackChannelMockServer(&requestPath, &requestQuery) + defer ts.Close() + + c, err := NewRestClient("test-token-very-authorized", WithBaseURL(ts.URL)) + if err != nil { + t.Fatalf("Received error initializing API client: %s", err.Error()) + return + } + _, err = c.SlackChannels().Get(context.Background(), "C111111111") + if err == nil { + t.Fatalf("expected ErrorNotFound in retrieving slack channel, got nil") + } + if !errors.Is(err, ErrorNotFound) { + t.Fatalf("expected ErrorNotFound in retrieving slack channel, got: %s", err) + } +} diff --git a/firehydrant/types.go b/firehydrant/types.go index e0852ae5..1a6c7b00 100644 --- a/firehydrant/types.go +++ b/firehydrant/types.go @@ -297,3 +297,16 @@ type UpdateFunctionalityRequest struct { Labels map[string]string `json:"labels"` Services []FunctionalityService `json:"services"` } + +// SlackChannelResponse is the response for retrieving Slack channel information, including FireHydrant ID. +// URL: GET https://api.firehydrant.io/v1/integrations/slack/channels?slack_channel_id={id} +type SlackChannelResponse struct { + ID string `json:"id"` + SlackChannelID string `json:"slack_channel_id"` + Name string `json:"name"` +} + +type SlackChannelsResponse struct { + Channels []*SlackChannelResponse `json:"data"` + Pagination *Pagination `json:"pagination,omitempty"` +} diff --git a/provider/provider.go b/provider/provider.go index aa927a6f..eebc349c 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -67,6 +67,7 @@ func Provider() *schema.Provider { "firehydrant_service": dataSourceService(), "firehydrant_services": dataSourceServices(), "firehydrant_severity": dataSourceSeverity(), + "firehydrant_slack_channel": dataSourceSlackChannel(), "firehydrant_task_list": dataSourceTaskList(), "firehydrant_team": dataSourceTeam(), "firehydrant_teams": dataSourceTeams(), diff --git a/provider/slack_channel_data.go b/provider/slack_channel_data.go new file mode 100644 index 00000000..f6aeb985 --- /dev/null +++ b/provider/slack_channel_data.go @@ -0,0 +1,45 @@ +package provider + +import ( + "context" + + "github.com/firehydrant/terraform-provider-firehydrant/firehydrant" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func dataSourceSlackChannel() *schema.Resource { + return &schema.Resource{ + Description: "The `firehydrant_slack_channel` data source allows access to the details of a Slack channel.", + ReadContext: dataFireHydrantSlackChannelRead, + Schema: map[string]*schema.Schema{ + "slack_channel_id": { + Description: "ID of the channel, provided by Slack.", + Type: schema.TypeString, + Required: true, + }, + "id": { + Description: "FireHydrant internal ID for the Slack channel.", + Type: schema.TypeString, + Computed: true, + }, + }, + } +} + +func dataFireHydrantSlackChannelRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + // Get the API client + firehydrantAPIClient := m.(firehydrant.Client) + + // Get the Slack channel + channelID := d.Get("slack_channel_id").(string) + slackChannel, err := firehydrantAPIClient.SlackChannels().Get(ctx, channelID) + if err != nil { + return diag.FromErr(err) + } + + // Set the ID + d.SetId(slackChannel.ID) + + return diag.Diagnostics{} +} diff --git a/provider/slack_channel_data_test.go b/provider/slack_channel_data_test.go new file mode 100644 index 00000000..0511a959 --- /dev/null +++ b/provider/slack_channel_data_test.go @@ -0,0 +1,42 @@ +package provider + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/firehydrant/terraform-provider-firehydrant/firehydrant" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func offlineSlackChannelsMockServer() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Write([]byte(`{ + "data": [{"id":"00000000-0000-4000-8000-000000000000","name":"#team-rocket","slack_channel_id":"C01010101Z"}], + "pagination": {"count":1,"page":1,"items":1,"pages":1,"last":1,"prev":null,"next":null} +}`)) + })) +} + +func TestOfflineSlackChannelsReadMemberID(t *testing.T) { + ts := offlineSlackChannelsMockServer() + defer ts.Close() + + c, err := firehydrant.NewRestClient("test-token-very-authorized", firehydrant.WithBaseURL(ts.URL)) + if err != nil { + t.Fatalf("Received error initializing API client: %s", err.Error()) + return + } + r := schema.TestResourceDataRaw(t, dataSourceSlackChannel().Schema, map[string]interface{}{ + "slack_channel_id": "C01010101Z", + }) + + d := dataFireHydrantSlackChannelRead(context.Background(), r, c) + if d.HasError() { + t.Fatalf("error reading on-call schedule: %v", d) + } + if id := r.Id(); id != "00000000-0000-4000-8000-000000000000" { + t.Fatalf("expected ID to be 00000000-0000-4000-8000-000000000000, got %s", id) + } +}