From 1b952330e159e9617cb3a759d5894404be163ee7 Mon Sep 17 00:00:00 2001 From: Mario Minardi Date: Thu, 1 Aug 2024 15:36:13 -0600 Subject: [PATCH] tailscale: add `webhook` resource (#384) Add a `tailscale_webhook` resource to allow for managing tailscale webhooks via terraform. Updates https://github.com/tailscale/corp/issues/21625 Signed-off-by: Mario Minardi --- docs/resources/webhook.md | 47 +++++ .../resources/tailscale_webhook/import.sh | 2 + .../resources/tailscale_webhook/resource.tf | 5 + go.mod | 4 +- go.sum | 10 +- tailscale/provider.go | 1 + tailscale/resource_webhook.go | 172 ++++++++++++++++++ tailscale/resource_webhook_test.go | 34 ++++ 8 files changed, 269 insertions(+), 6 deletions(-) create mode 100644 docs/resources/webhook.md create mode 100644 examples/resources/tailscale_webhook/import.sh create mode 100644 examples/resources/tailscale_webhook/resource.tf create mode 100644 tailscale/resource_webhook.go create mode 100644 tailscale/resource_webhook_test.go diff --git a/docs/resources/webhook.md b/docs/resources/webhook.md new file mode 100644 index 00000000..046d2844 --- /dev/null +++ b/docs/resources/webhook.md @@ -0,0 +1,47 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "tailscale_webhook Resource - terraform-provider-tailscale" +subcategory: "" +description: |- + The webhook resource allows you to configure webhook endpoints for your Tailscale network. See https://tailscale.com/kb/1213/webhooks for more information. +--- + +# tailscale_webhook (Resource) + +The webhook resource allows you to configure webhook endpoints for your Tailscale network. See https://tailscale.com/kb/1213/webhooks for more information. + +## Example Usage + +```terraform +resource "tailscale_webhook" "sample_webhook" { + endpoint_url = "https://example.com/webhook/endpoint" + provider_type = "slack" + subscriptions = ["nodeCreated", "userDeleted"] +} +``` + + +## Schema + +### Required + +- `endpoint_url` (String) The endpoint to send webhook events to. +- `subscriptions` (Set of String) The Tailscale events to subscribe this webhook to. See https://tailscale.com/kb/1213/webhooks#events for the list of valid events. + +### Optional + +- `provider_type` (String) The provider type of the endpoint URL. Also referred to as the 'destination' for the webhook in the admin panel. Webhook event payloads are formatted according to the provider type if it is set to a known value. Must be one of `slack`, `mattermost`, `googlechat`, or `discord` if set. + +### Read-Only + +- `id` (String) The ID of this resource. +- `secret` (String, Sensitive) The secret used for signing webhook payloads. Only set on resource creation. See https://tailscale.com/kb/1213/webhooks#webhook-secret for more information. + +## Import + +Import is supported using the following syntax: + +```shell +# Webhooks can be imported using the endpoint id, e.g., +terraform import tailscale_webhook.sample_webhook 123456789 +``` diff --git a/examples/resources/tailscale_webhook/import.sh b/examples/resources/tailscale_webhook/import.sh new file mode 100644 index 00000000..fda5d488 --- /dev/null +++ b/examples/resources/tailscale_webhook/import.sh @@ -0,0 +1,2 @@ +# Webhooks can be imported using the endpoint id, e.g., +terraform import tailscale_webhook.sample_webhook 123456789 diff --git a/examples/resources/tailscale_webhook/resource.tf b/examples/resources/tailscale_webhook/resource.tf new file mode 100644 index 00000000..8bf48d24 --- /dev/null +++ b/examples/resources/tailscale_webhook/resource.tf @@ -0,0 +1,5 @@ +resource "tailscale_webhook" "sample_webhook" { + endpoint_url = "https://example.com/webhook/endpoint" + provider_type = "slack" + subscriptions = ["nodeCreated", "userDeleted"] +} \ No newline at end of file diff --git a/go.mod b/go.mod index 76a4b484..8ba8f3e1 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/hashicorp/terraform-plugin-sdk/v2 v2.34.0 github.com/stretchr/testify v1.9.0 github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a - github.com/tailscale/tailscale-client-go v1.17.0 + github.com/tailscale/tailscale-client-go v1.17.1-0.20240724064152-5d834701bd85 golang.org/x/tools v0.23.0 tailscale.com v1.70.0 ) @@ -79,7 +79,7 @@ require ( golang.org/x/exp v0.0.0-20240119083558-1b970713d09a // indirect golang.org/x/mod v0.19.0 // indirect golang.org/x/net v0.27.0 // indirect - golang.org/x/oauth2 v0.19.0 // indirect + golang.org/x/oauth2 v0.21.0 // indirect golang.org/x/sync v0.7.0 // indirect golang.org/x/sys v0.22.0 // indirect golang.org/x/text v0.16.0 // indirect diff --git a/go.sum b/go.sum index 279a1998..53f02daf 100644 --- a/go.sum +++ b/go.sum @@ -188,8 +188,10 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw= github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8= -github.com/tailscale/tailscale-client-go v1.17.0 h1:4ZTsfB2hiiiIcx7prcXapZHO5K+TXB5fTdhGxdKGR88= -github.com/tailscale/tailscale-client-go v1.17.0/go.mod h1:Zxz9AWl4cNX8F+jE7iIeo6Me7dGPXNdCFIoVeovH6eI= +github.com/tailscale/tailscale-client-go v1.17.1-0.20240718200212-ff9b01c0d472 h1:BLqo/AEtgxdsqPC/WU0lNDwMLjQwvkXappShYmZrZ34= +github.com/tailscale/tailscale-client-go v1.17.1-0.20240718200212-ff9b01c0d472/go.mod h1:jbwJyHniK3nyLttwcDTXnfdDQEnADvc4VMOP8hZWnR0= +github.com/tailscale/tailscale-client-go v1.17.1-0.20240724064152-5d834701bd85 h1:F6nQg/GLWZDx27RPiAkJwCR3LYKYxBquUuiPcnuLesA= +github.com/tailscale/tailscale-client-go v1.17.1-0.20240724064152-5d834701bd85/go.mod h1:jbwJyHniK3nyLttwcDTXnfdDQEnADvc4VMOP8hZWnR0= github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= @@ -231,8 +233,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= -golang.org/x/oauth2 v0.19.0 h1:9+E/EZBCbTLNrbN35fHv/a/d/mOBatymz1zbtQrXpIg= -golang.org/x/oauth2 v0.19.0/go.mod h1:vYi7skDa1x015PmRRYZ7+s1cWyPgrPiSYRe4rnsexc8= +golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= +golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/tailscale/provider.go b/tailscale/provider.go index f1679ff8..9cab53a9 100644 --- a/tailscale/provider.go +++ b/tailscale/provider.go @@ -83,6 +83,7 @@ func Provider(options ...ProviderOption) *schema.Provider { "tailscale_tailnet_key": resourceTailnetKey(), "tailscale_device_tags": resourceDeviceTags(), "tailscale_device_key": resourceDeviceKey(), + "tailscale_webhook": resourceWebhook(), }, DataSourcesMap: map[string]*schema.Resource{ "tailscale_device": dataSourceDevice(), diff --git a/tailscale/resource_webhook.go b/tailscale/resource_webhook.go new file mode 100644 index 00000000..1bf00488 --- /dev/null +++ b/tailscale/resource_webhook.go @@ -0,0 +1,172 @@ +package tailscale + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + + "github.com/tailscale/tailscale-client-go/tailscale" +) + +func resourceWebhook() *schema.Resource { + return &schema.Resource{ + Description: "The webhook resource allows you to configure webhook endpoints for your Tailscale network. See https://tailscale.com/kb/1213/webhooks for more information.", + ReadContext: resourceWebhookRead, + CreateContext: resourceWebhookCreate, + UpdateContext: resourceWebhookUpdate, + DeleteContext: resourceWebhookDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + Schema: map[string]*schema.Schema{ + "endpoint_url": { + Type: schema.TypeString, + Description: "The endpoint to send webhook events to.", + Required: true, + ForceNew: true, + }, + "provider_type": { + Type: schema.TypeString, + Description: "The provider type of the endpoint URL. Also referred to as the 'destination' for the webhook in the admin panel. Webhook event payloads are formatted according to the provider type if it is set to a known value. Must be one of `slack`, `mattermost`, `googlechat`, or `discord` if set.", + Optional: true, + ForceNew: true, + ValidateFunc: validation.StringInSlice( + []string{ + string(tailscale.WebhookEmptyProviderType), + string(tailscale.WebhookSlackProviderType), + string(tailscale.WebhookMattermostProviderType), + string(tailscale.WebhookGoogleChatProviderType), + string(tailscale.WebhookDiscordProviderType), + }, + false, + ), + }, + "subscriptions": { + Type: schema.TypeSet, + Description: "The Tailscale events to subscribe this webhook to. See https://tailscale.com/kb/1213/webhooks#events for the list of valid events.", + Required: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + ValidateFunc: validation.StringInSlice( + []string{ + string(tailscale.WebhookNodeCreated), + string(tailscale.WebhookNodeNeedsApproval), + string(tailscale.WebhookNodeApproved), + string(tailscale.WebhookNodeKeyExpiringInOneDay), + string(tailscale.WebhookNodeKeyExpired), + string(tailscale.WebhookNodeDeleted), + string(tailscale.WebhookPolicyUpdate), + string(tailscale.WebhookUserCreated), + string(tailscale.WebhookUserNeedsApproval), + string(tailscale.WebhookUserSuspended), + string(tailscale.WebhookUserRestored), + string(tailscale.WebhookUserDeleted), + string(tailscale.WebhookUserApproved), + string(tailscale.WebhookUserRoleUpdated), + string(tailscale.WebhookSubnetIPForwardingNotEnabled), + string(tailscale.WebhookExitNodeIPForwardingNotEnabled), + }, + false, + ), + }, + }, + "secret": { + Type: schema.TypeString, + Description: "The secret used for signing webhook payloads. Only set on resource creation. See https://tailscale.com/kb/1213/webhooks#webhook-secret for more information.", + Sensitive: true, + Computed: true, + }, + }, + } +} + +func resourceWebhookCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + client := m.(*tailscale.Client) + + endpointURL := d.Get("endpoint_url").(string) + providerType := tailscale.WebhookProviderType(d.Get("provider_type").(string)) + subscriptions := d.Get("subscriptions").(*schema.Set).List() + + var requestSubscriptions []tailscale.WebhookSubscriptionType + for _, subscription := range subscriptions { + requestSubscriptions = append(requestSubscriptions, tailscale.WebhookSubscriptionType(subscription.(string))) + } + + request := tailscale.CreateWebhookRequest{ + EndpointURL: endpointURL, + ProviderType: providerType, + Subscriptions: requestSubscriptions, + } + + webhook, err := client.CreateWebhook(ctx, request) + if err != nil { + return diagnosticsError(err, "Failed to create webhook") + } + + d.SetId(webhook.EndpointID) + // Secret is only returned on create. + d.Set("secret", webhook.Secret) + + return resourceWebhookRead(ctx, d, m) +} + +func resourceWebhookRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + client := m.(*tailscale.Client) + + webhook, err := client.Webhook(ctx, d.Id()) + if err != nil { + return diagnosticsError(err, "Failed to fetch webhook") + } + + if err = d.Set("endpoint_url", webhook.EndpointURL); err != nil { + return diagnosticsError(err, "Failed to set endpoint_url field") + } + + if err = d.Set("provider_type", webhook.ProviderType); err != nil { + return diagnosticsError(err, "Failed to set provider_type field") + } + + if err = d.Set("subscriptions", webhook.Subscriptions); err != nil { + return diagnosticsError(err, "Failed to set subscriptions field") + } + + if err = d.Set("secret", d.Get("secret").(string)); err != nil { + return diagnosticsError(err, "Failed to set secret field") + } + + return nil +} + +func resourceWebhookUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + if !d.HasChange("subscriptions") { + return resourceWebhookRead(ctx, d, m) + } + + client := m.(*tailscale.Client) + subscriptions := d.Get("subscriptions").(*schema.Set).List() + + var requestSubscriptions []tailscale.WebhookSubscriptionType + for _, subscription := range subscriptions { + requestSubscriptions = append(requestSubscriptions, tailscale.WebhookSubscriptionType(subscription.(string))) + } + + _, err := client.UpdateWebhook(ctx, d.Id(), requestSubscriptions) + if err != nil { + return diagnosticsError(err, "Failed to update webhook") + } + + return resourceWebhookRead(ctx, d, m) +} + +func resourceWebhookDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + client := m.(*tailscale.Client) + + err := client.DeleteWebhook(ctx, d.Id()) + if err != nil { + return diagnosticsError(err, "Failed to delete webhook") + } + + return nil +} diff --git a/tailscale/resource_webhook_test.go b/tailscale/resource_webhook_test.go new file mode 100644 index 00000000..6bc6848f --- /dev/null +++ b/tailscale/resource_webhook_test.go @@ -0,0 +1,34 @@ +package tailscale_test + +import ( + "net/http" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + + "github.com/tailscale/tailscale-client-go/tailscale" +) + +const testWebhook = ` + resource "tailscale_webhook" "test_webhook" { + endpoint_url = "https://example.com/endpoint" + provider_type = "slack" + subscriptions = ["userNeedsApproval", "nodeCreated"] + }` + +func TestProvider_TailscaleWebhook(t *testing.T) { + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + PreCheck: func() { + testServer.ResponseCode = http.StatusOK + testServer.ResponseBody = tailscale.Webhook{ + EndpointID: "12345", + } + }, + ProviderFactories: testProviderFactories(t), + Steps: []resource.TestStep{ + testResourceCreated("tailscale_webhook.test_webhook", testWebhook), + testResourceDestroyed("tailscale_webhook.test_webhook", testWebhook), + }, + }) +}