From c91f93434b6d6be5f69896d3c13dbf419516d124 Mon Sep 17 00:00:00 2001 From: Kyle McGuire Date: Tue, 4 Jun 2024 10:46:20 -0700 Subject: [PATCH 1/3] Add Doppler Webhook resource --- doppler/api.go | 139 +++++++++++++++++++ doppler/models.go | 18 +++ doppler/provider.go | 2 + doppler/resource_webhook.go | 259 ++++++++++++++++++++++++++++++++++++ 4 files changed, 418 insertions(+) create mode 100644 doppler/resource_webhook.go diff --git a/doppler/api.go b/doppler/api.go index 7aec820..866c248 100644 --- a/doppler/api.go +++ b/doppler/api.go @@ -687,6 +687,145 @@ func (client APIClient) DeleteEnvironment(ctx context.Context, project string, s return nil } +// Webhooks + +func (client APIClient) GetWebhook(ctx context.Context, project string, slug string) (*Webhook, error) { + params := []QueryParam{ + {Key: "project", Value: project}, + } + response, err := client.PerformRequestWithRetry(ctx, "GET", fmt.Sprintf("/v3/webhooks/webhook/%s", url.QueryEscape(slug)), params, nil) + if err != nil { + return nil, err + } + var result WebhookResponse + if err = json.Unmarshal(response.Body, &result); err != nil { + return nil, &APIError{Err: err, Message: "Unable to parse webhook"} + } + return &result.Webhook, nil +} + +type CreateWebhookOptionalParameters struct { + Secret string + Auth *WebhookAuth + WebhookPayload string + EnabledConfigs []string +} + +func (client APIClient) CreateWebhook(ctx context.Context, project string, url string, enabled bool, options *CreateWebhookOptionalParameters) (*Webhook, error) { + params := []QueryParam{ + {Key: "project", Value: project}, + } + + payload := map[string]interface{}{ + "url": url, + "enabled": enabled, + } + + if options != nil { + if options.Secret != "" { + payload["secret"] = options.Secret + } + if options.Auth != nil { + payload["authentication"] = *options.Auth + } + if options.WebhookPayload != "" { + payload["payload"] = options.WebhookPayload + } + if options.EnabledConfigs != nil { + payload["enableConfigs"] = options.EnabledConfigs + } + } + + body, err := json.Marshal(payload) + if err != nil { + return nil, &APIError{Err: err, Message: "Unable to serialize webhook"} + } + + response, err := client.PerformRequestWithRetry(ctx, "POST", "/v3/webhooks", params, body) + if err != nil { + return nil, err + } + + var result WebhookResponse + if err = json.Unmarshal(response.Body, &result); err != nil { + return nil, &APIError{Err: err, Message: "Unable to parse webhook"} + } + return &result.Webhook, nil +} + +func (client APIClient) EnableWebhook(ctx context.Context, project string, slug string) (*Webhook, error) { + params := []QueryParam{ + {Key: "project", Value: project}, + } + response, err := client.PerformRequestWithRetry(ctx, "POST", fmt.Sprintf("/v3/webhooks/webhook/%s/enable", url.QueryEscape(slug)), params, nil) + if err != nil { + return nil, err + } + + var result WebhookResponse + if err = json.Unmarshal(response.Body, &result); err != nil { + return nil, &APIError{Err: err, Message: "Unable to parse webhook"} + } + return &result.Webhook, nil +} + +func (client APIClient) DisableWebhook(ctx context.Context, project string, slug string) (*Webhook, error) { + params := []QueryParam{ + {Key: "project", Value: project}, + } + response, err := client.PerformRequestWithRetry(ctx, "POST", fmt.Sprintf("/v3/webhooks/webhook/%s/disable", url.QueryEscape(slug)), params, nil) + if err != nil { + return nil, err + } + + var result WebhookResponse + if err = json.Unmarshal(response.Body, &result); err != nil { + return nil, &APIError{Err: err, Message: "Unable to parse webhook"} + } + return &result.Webhook, nil +} + +func (client APIClient) UpdateWebhook(ctx context.Context, project string, slug string, webhookUrl string, secret string, webhookPayload string, enabledConfigs []string, disabledConfigs []string, auth WebhookAuth) (*Webhook, error) { + params := []QueryParam{ + {Key: "project", Value: project}, + } + + payload := map[string]interface{}{} + payload["url"] = webhookUrl + payload["secret"] = secret + payload["payload"] = webhookPayload + payload["enableConfigs"] = enabledConfigs + payload["disableConfigs"] = disabledConfigs + payload["authentication"] = auth + + body, err := json.Marshal(payload) + if err != nil { + return nil, &APIError{Err: err, Message: "Unable to serialize webhook"} + } + + response, err := client.PerformRequestWithRetry(ctx, "PATCH", fmt.Sprintf("/v3/webhooks/webhook/%s", url.QueryEscape(slug)), params, body) + if err != nil { + return nil, err + } + + var result WebhookResponse + if err = json.Unmarshal(response.Body, &result); err != nil { + return nil, &APIError{Err: err, Message: "Unable to parse webhook"} + } + return &result.Webhook, nil +} + +func (client APIClient) DeleteWebhook(ctx context.Context, project string, slug string) error { + params := []QueryParam{ + {Key: "project", Value: project}, + } + _, err := client.PerformRequestWithRetry(ctx, "DELETE", fmt.Sprintf("/v3/webhooks/webhook/%s", url.QueryEscape(slug)), params, nil) + if err != nil { + return err + } + return nil +} + // Configs func (client APIClient) GetConfig(ctx context.Context, project string, name string) (*Config, error) { diff --git a/doppler/models.go b/doppler/models.go index 972da6b..a1f10ac 100644 --- a/doppler/models.go +++ b/doppler/models.go @@ -151,6 +151,24 @@ func parseEnvironmentResourceId(id string) (project string, name string, err err return tokens[0], tokens[1], nil } +type WebhookAuth struct { + Type string `json:"type"` + Token string `json:"token"` + Username string `json:"username"` + Password string `json:"password"` +} + +type Webhook struct { + Slug string `json:"id"` + Url string `json:"url"` + Enabled bool `json:"enabled"` + EnabledConfigs []string `json:"enabledConfigs"` +} + +type WebhookResponse struct { + Webhook Webhook `json:"webhook"` +} + type Config struct { Slug string `json:"slug"` Name string `json:"name"` diff --git a/doppler/provider.go b/doppler/provider.go index cb55373..cc8564c 100644 --- a/doppler/provider.go +++ b/doppler/provider.go @@ -46,6 +46,8 @@ func Provider() *schema.Provider { "doppler_group": resourceGroup(), "doppler_group_member": resourceGroupMemberWorkplaceUser(), + "doppler_webhook": resourceWebhook(), + "doppler_project_member_group": resourceProjectMemberGroup(), "doppler_project_member_service_account": resourceProjectMemberServiceAccount(), diff --git a/doppler/resource_webhook.go b/doppler/resource_webhook.go new file mode 100644 index 0000000..943aeee --- /dev/null +++ b/doppler/resource_webhook.go @@ -0,0 +1,259 @@ +package doppler + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceWebhook() *schema.Resource { + return &schema.Resource{ + CreateContext: resourceWebhookCreate, + ReadContext: resourceWebhookRead, + UpdateContext: resourceWebhookUpdate, + DeleteContext: resourceWebhookDelete, + Schema: map[string]*schema.Schema{ + "slug": { + Description: "The slug of the Webhook", + Type: schema.TypeString, + Computed: true, + }, + "project": { + Description: "The name of the Doppler project where the webhook is located", + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "url": { + Description: "The URL of the webhook endpoint", + Type: schema.TypeString, + Required: true, + }, + "enabled": { + Description: "Whether the webhook is enabled or disabled. Default to true.", + Type: schema.TypeBool, + Optional: true, + Default: true, + }, + "secret": { + Description: "Secret used for request signing", + Type: schema.TypeString, + Optional: true, + Sensitive: true, + }, + "authentication": { + Description: "Authentication method used by the webhook", + Type: schema.TypeList, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "type": { + Type: schema.TypeString, + Required: true, + }, + "token": { + Type: schema.TypeString, + Optional: true, + Sensitive: true, + }, + "username": { + Type: schema.TypeString, + Optional: true, + }, + "password": { + Type: schema.TypeString, + Optional: true, + Sensitive: true, + }, + }, + }, + MaxItems: 1, + Optional: true, + }, + "payload": { + Description: "The webhook's payload as a JSON string. Leave empty to use the default webhook payload", + Type: schema.TypeString, + Optional: true, + Sensitive: true, + }, + "enabled_configs": { + Description: "Configs this webhook will trigger for", + Type: schema.TypeSet, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + Optional: true, + }, + }, + } +} + +func resourceWebhookCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + client := m.(APIClient) + + var diags diag.Diagnostics + project := d.Get("project").(string) + url := d.Get("url").(string) + enabled := d.Get("enabled").(bool) + secret := d.Get("secret").(string) + payload := d.Get("payload").(string) + + rawEnabledConfigs := d.Get("enabled_configs").(*schema.Set).List() + enabledConfigs := make([]string, len(rawEnabledConfigs)) + for i, v := range rawEnabledConfigs { + enabledConfigs[i] = v.(string) + } + + options := CreateWebhookOptionalParameters{Secret: secret, WebhookPayload: payload, EnabledConfigs: enabledConfigs} + + authConfigList := d.Get("authentication").([]interface{}) + + if len(authConfigList) > 0 { + authMap, ok := authConfigList[0].(map[string]interface{}) // schema allows only 1 item + if !ok { + return diag.FromErr(fmt.Errorf("unexpected type for authentication element: %T", authConfigList)) + } + + options.Auth = &WebhookAuth{ + Type: authMap["type"].(string), + Token: authMap["token"].(string), + Username: authMap["username"].(string), + Password: authMap["password"].(string), + } + } + + webhook, err := client.CreateWebhook(ctx, project, url, enabled, &options) + if err != nil { + return diag.FromErr(err) + } + + d.SetId(webhook.Slug) + + return diags +} + +func resourceWebhookUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + client := m.(APIClient) + + var diags diag.Diagnostics + slug := d.Id() + project := d.Get("project").(string) + secret := d.Get("secret").(string) + payload := d.Get("payload").(string) + + if d.HasChange("enabled") { + if d.Get("enabled").(bool) { + _, err := client.EnableWebhook(ctx, project, slug) + if err != nil { + return diag.FromErr(err) + } + } else { + _, err := client.DisableWebhook(ctx, project, slug) + if err != nil { + return diag.FromErr(err) + } + } + } + + url := d.Get("url").(string) + enabledConfigs := []string{} + disabledConfigs := []string{} + if d.HasChange("enabled_configs") { + oldRawEnabledConfigs, newRawEnabledConfigs := d.GetChange("enabled_configs") + oldRawEnabledConfigsArr := oldRawEnabledConfigs.(*schema.Set).List() + newRawEnabledConfigsArr := newRawEnabledConfigs.(*schema.Set).List() + oldEnabledConfigsMap := make(map[string]string) + for _, v := range oldRawEnabledConfigsArr { + oldEnabledConfigsMap[v.(string)] = v.(string) + } + newEnabledConfigsMap := make(map[string]string) + for _, v := range newRawEnabledConfigsArr { + newEnabledConfigsMap[v.(string)] = v.(string) + } + + for _, v := range newRawEnabledConfigsArr { + if _, ok := oldEnabledConfigsMap[v.(string)]; !ok { + enabledConfigs = append(enabledConfigs, v.(string)) + } + } + + for _, v := range oldRawEnabledConfigsArr { + if _, ok := newEnabledConfigsMap[v.(string)]; !ok { + disabledConfigs = append(disabledConfigs, v.(string)) + } + } + } + + authConfigList := d.Get("authentication").([]interface{}) + var auth WebhookAuth + + if len(authConfigList) > 0 { + authMap, ok := authConfigList[0].(map[string]interface{}) // schema allows only 1 item + if !ok { + return diag.FromErr(fmt.Errorf("unexpected type for authentication element: %T", authConfigList)) + } + + auth = WebhookAuth{ + Type: authMap["type"].(string), + Token: authMap["token"].(string), + Username: authMap["username"].(string), + Password: authMap["password"].(string), + } + } else { + auth = WebhookAuth{Type: "None"} + } + + webhook, err := client.UpdateWebhook(ctx, project, slug, url, secret, payload, enabledConfigs, disabledConfigs, auth) + + if err != nil { + return diag.FromErr(err) + } + d.SetId(webhook.Slug) + return diags +} + +func resourceWebhookRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + client := m.(APIClient) + + var diags diag.Diagnostics + slug := d.Id() + project := d.Get("project").(string) + + webhook, err := client.GetWebhook(ctx, project, slug) + if err != nil { + return handleNotFoundError(err, d) + } + + if err = d.Set("slug", webhook.Slug); err != nil { + return diag.FromErr(err) + } + + if err = d.Set("url", webhook.Url); err != nil { + return diag.FromErr(err) + } + + if err = d.Set("enabled", webhook.Enabled); err != nil { + return diag.FromErr(err) + } + + if err = d.Set("enabled_configs", webhook.EnabledConfigs); err != nil { + return diag.FromErr(err) + } + + return diags +} + +func resourceWebhookDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + client := m.(APIClient) + + var diags diag.Diagnostics + slug := d.Id() + project := d.Get("project").(string) + + if err := client.DeleteWebhook(ctx, project, slug); err != nil { + return diag.FromErr(err) + } + + return diags +} From 7fc741c7c738f769ad1d8590f2b9289eed7fe498 Mon Sep 17 00:00:00 2001 From: Kyle McGuire Date: Tue, 28 May 2024 11:57:33 -0700 Subject: [PATCH 2/3] Add Webhook example and md template --- examples/resources/webhook.tf | 14 ++++++++++++++ templates/resources/webhook.md.tmpl | 25 +++++++++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 examples/resources/webhook.tf create mode 100644 templates/resources/webhook.md.tmpl diff --git a/examples/resources/webhook.tf b/examples/resources/webhook.tf new file mode 100644 index 0000000..926b0e8 --- /dev/null +++ b/examples/resources/webhook.tf @@ -0,0 +1,14 @@ +resource "doppler_webhook" "ci" { + project = doppler_project.test_proj.name + url = "https://localhost/webhook" + secret = "my signing secret-2" + enabled = true + enabled_configs = [doppler_config.ci_github.name] + authentication { + type = "Bearer" + token = "my bearer token" + } + payload = jsonencode({ + myKey = "my value" + }) +} \ No newline at end of file diff --git a/templates/resources/webhook.md.tmpl b/templates/resources/webhook.md.tmpl new file mode 100644 index 0000000..9d3b730 --- /dev/null +++ b/templates/resources/webhook.md.tmpl @@ -0,0 +1,25 @@ +--- +page_title: "doppler_webhook Resource - terraform-provider-doppler" +subcategory: "" +description: |- + Manage a Doppler Webhook. +--- + +# doppler_webhook (Resource) + +Manage a Doppler Webhook. + +## Example Usage + +{{tffile "examples/resources/webhook.tf"}} + +{{ .SchemaMarkdown | trimspace }} + +## State Management + +For security reasons, the Doppler API does not return the fields listed below, which prevents the Terraform provider from checking this external state. +In other words, the Terraform provider can be used to update these webhook fields but it will be unaware of external changes. + +- `secret` +- `authentication` +- `payload` From 97d07a94990b81b9ad08ccf1589705dd872eb300 Mon Sep 17 00:00:00 2001 From: Kyle McGuire Date: Tue, 28 May 2024 13:31:49 -0700 Subject: [PATCH 3/3] Run make tfdocs --- docs/resources/webhook.md | 72 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 docs/resources/webhook.md diff --git a/docs/resources/webhook.md b/docs/resources/webhook.md new file mode 100644 index 0000000..c67fb58 --- /dev/null +++ b/docs/resources/webhook.md @@ -0,0 +1,72 @@ +--- +page_title: "doppler_webhook Resource - terraform-provider-doppler" +subcategory: "" +description: |- + Manage a Doppler Webhook. +--- + +# doppler_webhook (Resource) + +Manage a Doppler Webhook. + +## Example Usage + +```terraform +resource "doppler_webhook" "ci" { + project = doppler_project.test_proj.name + url = "https://localhost/webhook" + secret = "my signing secret-2" + enabled = true + enabled_configs = [doppler_config.ci_github.name] + authentication { + type = "Bearer" + token = "my bearer token" + } + payload = jsonencode({ + myKey = "my value" + }) +} +``` + + +## Schema + +### Required + +- `project` (String) The name of the Doppler project where the webhook is located +- `url` (String) The URL of the webhook endpoint + +### Optional + +- `authentication` (Block List, Max: 1) Authentication method used by the webhook (see [below for nested schema](#nestedblock--authentication)) +- `enabled` (Boolean) Whether the webhook is enabled or disabled. Default to true. +- `enabled_configs` (Set of String) Configs this webhook will trigger for +- `payload` (String, Sensitive) The webhook's payload as a JSON string. Leave empty to use the default webhook payload +- `secret` (String, Sensitive) Secret used for request signing + +### Read-Only + +- `id` (String) The ID of this resource. +- `slug` (String) The slug of the Webhook + + +### Nested Schema for `authentication` + +Required: + +- `type` (String) + +Optional: + +- `password` (String, Sensitive) +- `token` (String, Sensitive) +- `username` (String) + +## State Management + +For security reasons, the Doppler API does not return the fields listed below, which prevents the Terraform provider from checking this external state. +In other words, the Terraform provider can be used to update these webhook fields but it will be unaware of external changes. + +- `secret` +- `authentication` +- `payload`