diff --git a/docs/resources/contacts.md b/docs/resources/contacts.md new file mode 100644 index 00000000..ea2b8245 --- /dev/null +++ b/docs/resources/contacts.md @@ -0,0 +1,77 @@ +--- +# generated by /~https://github.com/hashicorp/terraform-plugin-docs +page_title: "tailscale_contacts Resource - terraform-provider-tailscale" +subcategory: "" +description: |- + The contacts resource allows you to configure contact details for your Tailscale network. See https://tailscale.com/kb/1224/contact-preferences for more information. + Destroying this resource does not unset or modify values in the tailscale control plane, and simply removes the resource from Terraform state. +--- + +# tailscale_contacts (Resource) + +The contacts resource allows you to configure contact details for your Tailscale network. See https://tailscale.com/kb/1224/contact-preferences for more information. + +Destroying this resource does not unset or modify values in the tailscale control plane, and simply removes the resource from Terraform state. + +## Example Usage + +```terraform +resource "tailscale_contacts" "sample_contacts" { + account { + email = "account@example.com" + } + + support { + email = "support@example.com" + } + + security { + email = "security@example.com" + } +} +``` + + +## Schema + +### Required + +- `account` (Block Set, Min: 1, Max: 1) Configuration for communications about important changes to your tailnet (see [below for nested schema](#nestedblock--account)) +- `security` (Block Set, Min: 1, Max: 1) Configuration for communications about security issues affecting your tailnet (see [below for nested schema](#nestedblock--security)) +- `support` (Block Set, Min: 1, Max: 1) Configuration for communications about misconfigurations in your tailnet (see [below for nested schema](#nestedblock--support)) + +### Read-Only + +- `id` (String) The ID of this resource. + + +### Nested Schema for `account` + +Required: + +- `email` (String) Email address to send communications to + + + +### Nested Schema for `security` + +Required: + +- `email` (String) Email address to send communications to + + + +### Nested Schema for `support` + +Required: + +- `email` (String) Email address to send communications to + +## Import + +Import is supported using the following syntax: + +```shell +# ID doesn't matter. +terraform import tailscale_contacts.sample_contacts contacts +``` diff --git a/examples/resources/tailscale_contacts/import.sh b/examples/resources/tailscale_contacts/import.sh new file mode 100644 index 00000000..7b65252e --- /dev/null +++ b/examples/resources/tailscale_contacts/import.sh @@ -0,0 +1,2 @@ +# ID doesn't matter. +terraform import tailscale_contacts.sample_contacts contacts diff --git a/examples/resources/tailscale_contacts/resource.tf b/examples/resources/tailscale_contacts/resource.tf new file mode 100644 index 00000000..e6913c37 --- /dev/null +++ b/examples/resources/tailscale_contacts/resource.tf @@ -0,0 +1,13 @@ +resource "tailscale_contacts" "sample_contacts" { + account { + email = "account@example.com" + } + + support { + email = "support@example.com" + } + + security { + email = "security@example.com" + } +} diff --git a/go.mod b/go.mod index 8ba8f3e1..8486fbb8 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.1-0.20240724064152-5d834701bd85 + github.com/tailscale/tailscale-client-go v1.17.1-0.20240729175651-90a1e935cc19 golang.org/x/tools v0.23.0 tailscale.com v1.70.0 ) diff --git a/go.sum b/go.sum index 53f02daf..0a2b6ace 100644 --- a/go.sum +++ b/go.sum @@ -188,10 +188,8 @@ 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.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/tailscale/tailscale-client-go v1.17.1-0.20240729175651-90a1e935cc19 h1:fRLv1yZH1ueL1cnpLhOnOymoBfMCIviCn0e0VkAjkK4= +github.com/tailscale/tailscale-client-go v1.17.1-0.20240729175651-90a1e935cc19/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= diff --git a/tailscale/provider.go b/tailscale/provider.go index 9cab53a9..8b3f5c35 100644 --- a/tailscale/provider.go +++ b/tailscale/provider.go @@ -84,6 +84,7 @@ func Provider(options ...ProviderOption) *schema.Provider { "tailscale_device_tags": resourceDeviceTags(), "tailscale_device_key": resourceDeviceKey(), "tailscale_webhook": resourceWebhook(), + "tailscale_contacts": resourceContacts(), }, DataSourcesMap: map[string]*schema.Resource{ "tailscale_device": dataSourceDevice(), diff --git a/tailscale/resource_contacts.go b/tailscale/resource_contacts.go new file mode 100644 index 00000000..1c6bbefd --- /dev/null +++ b/tailscale/resource_contacts.go @@ -0,0 +1,181 @@ +package tailscale + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + + "github.com/tailscale/tailscale-client-go/tailscale" +) + +const resourceContactsDescription = `The contacts resource allows you to configure contact details for your Tailscale network. See https://tailscale.com/kb/1224/contact-preferences for more information. + +Destroying this resource does not unset or modify values in the tailscale control plane, and simply removes the resource from Terraform state. +` + +func resourceContacts() *schema.Resource { + return &schema.Resource{ + Description: resourceContactsDescription, + ReadContext: resourceContactsRead, + CreateContext: resourceContactsCreate, + UpdateContext: resourceContactsUpdate, + DeleteContext: resourceContactsDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + Schema: map[string]*schema.Schema{ + "account": { + Type: schema.TypeSet, + Description: "Configuration for communications about important changes to your tailnet", + Required: true, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "email": { + Type: schema.TypeString, + Description: "Email address to send communications to", + Required: true, + }, + }, + }, + }, + "support": { + Type: schema.TypeSet, + Description: "Configuration for communications about misconfigurations in your tailnet", + Required: true, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "email": { + Type: schema.TypeString, + Description: "Email address to send communications to", + Required: true, + }, + }, + }, + }, + "security": { + Type: schema.TypeSet, + Description: "Configuration for communications about security issues affecting your tailnet", + Required: true, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "email": { + Type: schema.TypeString, + Description: "Email address to send communications to", + Required: true, + }, + }, + }, + }, + }, + } +} + +func resourceContactsCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + client := m.(*tailscale.Client) + + if diagErr := updateContact(ctx, client, d, tailscale.ContactAccount); diagErr != nil { + return diagErr + } + + if diagErr := updateContact(ctx, client, d, tailscale.ContactSupport); diagErr != nil { + return diagErr + } + + if diagErr := updateContact(ctx, client, d, tailscale.ContactSecurity); diagErr != nil { + return diagErr + } + + d.SetId(createUUID()) + return resourceContactsRead(ctx, d, m) +} + +func resourceContactsRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + client := m.(*tailscale.Client) + + contacts, err := client.Contacts(ctx) + if err != nil { + return diagnosticsError(err, "Failed to fetch contacts") + } + + if err = d.Set("account", buildContactMap(contacts.Account)); err != nil { + return diagnosticsError(err, "Failed to set account field") + } + + if err = d.Set("support", buildContactMap(contacts.Support)); err != nil { + return diagnosticsError(err, "Failed to set support field") + } + + if err = d.Set("security", buildContactMap(contacts.Security)); err != nil { + return diagnosticsError(err, "Failed to set security field") + } + + return nil +} + +func resourceContactsUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + client := m.(*tailscale.Client) + + if d.HasChange("account") { + if diagErr := updateContact(ctx, client, d, tailscale.ContactAccount); diagErr != nil { + return diagErr + } + } + + if d.HasChange("support") { + if diagErr := updateContact(ctx, client, d, tailscale.ContactSupport); diagErr != nil { + return diagErr + } + } + + if d.HasChange("security") { + if diagErr := updateContact(ctx, client, d, tailscale.ContactSecurity); diagErr != nil { + return diagErr + } + } + + return resourceContactsRead(ctx, d, m) +} + +func resourceContactsDelete(_ context.Context, _ *schema.ResourceData, _ interface{}) diag.Diagnostics { + // Deleting is a no-op since we cannot have unset contact information. + // Deletion in this context is simply removing from terraform state. + const diagDetail = `This resource has been successfully destroyed, but values in tailscale will remain set. +See https://tailscale.com/kb/1224/contact-preferences to learn more.` + + return diag.Diagnostics{ + diag.Diagnostic{ + Severity: diag.Warning, + Summary: "Destroying tailscale_contacts does not unset contact values on tailscale", + Detail: diagDetail, + }, + } +} + +// buildContactMap transforms a tailscale.Contact into an equivalnet single element +// slice of map[string]interface{} so that it can be set on a schema.TypeSet property +// in the resource. +func buildContactMap(contact tailscale.Contact) []map[string]interface{} { + return []map[string]interface{}{ + { + "email": contact.Email, + }, + } +} + +// updateContact updates the contact specified by the tailscale.ContactType by +// reading the resource property with the correct name and using it to build a +// request to the underlying client. +func updateContact(ctx context.Context, client *tailscale.Client, d *schema.ResourceData, contactType tailscale.ContactType) diag.Diagnostics { + contact := d.Get(string(contactType)).(*schema.Set).List() + contactEmail := contact[0].(map[string]interface{})["email"].(string) + + if err := client.UpdateContact(ctx, contactType, tailscale.UpdateContactRequest{Email: &contactEmail}); err != nil { + return diagnosticsError(err, "Failed to create contacts") + } + + return nil +} diff --git a/tailscale/resource_contacts_test.go b/tailscale/resource_contacts_test.go new file mode 100644 index 00000000..4ed40cb7 --- /dev/null +++ b/tailscale/resource_contacts_test.go @@ -0,0 +1,220 @@ +package tailscale_test + +import ( + "context" + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + + "github.com/tailscale/tailscale-client-go/tailscale" +) + +const testContactsBasic = ` + resource "tailscale_contacts" "test_contacts" { + account { + email = "account@example.com" + } + + support { + email = "support@example.com" + } + + security { + email = "security@example.com" + } + }` + +const testContactsUpdated = ` + resource "tailscale_contacts" "test_contacts" { + account { + email = "otheraccount@example.com" + } + + support { + email = "support@example.com" + } + + security { + email = "security2@example.com" + } + }` + +var expectedContactsBasic = &tailscale.Contacts{ + Account: tailscale.Contact{ + Email: "account@example.com", + }, + Support: tailscale.Contact{ + Email: "support@example.com", + }, + Security: tailscale.Contact{ + Email: "security@example.com", + }, +} + +var expectedContactsUpdated = &tailscale.Contacts{ + Account: tailscale.Contact{ + Email: "otheraccount@example.com", + }, + Support: tailscale.Contact{ + Email: "support@example.com", + }, + Security: tailscale.Contact{ + Email: "security2@example.com", + }, +} + +func TestAccTailscaleContacts_Basic(t *testing.T) { + contacts := &tailscale.Contacts{} + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProviderFactories: testAccProviderFactories(t), + CheckDestroy: testAccCheckContactsDestroyBasic, + Steps: []resource.TestStep{ + { + Config: testContactsBasic, + Check: resource.ComposeTestCheckFunc( + testAccCheckContactsExists("tailscale_contacts.test_contacts", contacts), + testAccCheckContactsPropertiesBasic(contacts), + resource.TestCheckResourceAttr("tailscale_contacts.test_contacts", "account.0.email", "account@example.com"), + resource.TestCheckResourceAttr("tailscale_contacts.test_contacts", "support.0.email", "support@example.com"), + resource.TestCheckResourceAttr("tailscale_contacts.test_contacts", "security.0.email", "security@example.com"), + ), + }, + { + ResourceName: "tailscale_contacts.test_contacts", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccTailscaleContacts_Update(t *testing.T) { + contacts := &tailscale.Contacts{} + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProviderFactories: testAccProviderFactories(t), + CheckDestroy: testAccCheckContactsDestroyUpdated, + Steps: []resource.TestStep{ + { + Config: testContactsBasic, + Check: resource.ComposeTestCheckFunc( + testAccCheckContactsExists("tailscale_contacts.test_contacts", contacts), + testAccCheckContactsPropertiesBasic(contacts), + resource.TestCheckResourceAttr("tailscale_contacts.test_contacts", "account.0.email", "account@example.com"), + resource.TestCheckResourceAttr("tailscale_contacts.test_contacts", "support.0.email", "support@example.com"), + resource.TestCheckResourceAttr("tailscale_contacts.test_contacts", "security.0.email", "security@example.com"), + ), + }, + { + Config: testContactsUpdated, + Check: resource.ComposeTestCheckFunc( + testAccCheckContactsExists("tailscale_contacts.test_contacts", contacts), + testAccCheckContactsPropertiesUpdated(contacts), + resource.TestCheckResourceAttr("tailscale_contacts.test_contacts", "account.0.email", "otheraccount@example.com"), + resource.TestCheckResourceAttr("tailscale_contacts.test_contacts", "support.0.email", "support@example.com"), + resource.TestCheckResourceAttr("tailscale_contacts.test_contacts", "security.0.email", "security2@example.com"), + ), + }, + { + ResourceName: "tailscale_contacts.test_contacts", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func testAccCheckContactsExists(resourceName string, contacts *tailscale.Contacts) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("resource not found: %s", resourceName) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("resource has no ID set") + } + + client := testAccProvider.Meta().(*tailscale.Client) + out, err := client.Contacts(context.Background()) + if err != nil { + return err + } + + *contacts = *out + return nil + } +} + +func testAccCheckContactsPropertiesBasic(contacts *tailscale.Contacts) resource.TestCheckFunc { + return func(s *terraform.State) error { + if err := checkContacts(contacts, expectedContactsBasic); err != nil { + return err + } + + return nil + } +} + +func testAccCheckContactsPropertiesUpdated(contacts *tailscale.Contacts) resource.TestCheckFunc { + return func(s *terraform.State) error { + if err := checkContacts(contacts, expectedContactsUpdated); err != nil { + return err + } + + return nil + } +} + +func testAccCheckContactsDestroyBasic(s *terraform.State) error { + return testAccCheckContactsDestroy(s, expectedContactsBasic) +} + +func testAccCheckContactsDestroyUpdated(s *terraform.State) error { + return testAccCheckContactsDestroy(s, expectedContactsUpdated) +} + +func testAccCheckContactsDestroy(s *terraform.State, expectedContacts *tailscale.Contacts) error { + client := testAccProvider.Meta().(*tailscale.Client) + + for _, rs := range s.RootModule().Resources { + if rs.Type != "tailscale_contacts" { + continue + } + + if rs.Primary.ID == "" { + return fmt.Errorf("resource has no ID set") + } + + // Contacts are not destroyed in the control plane upon resource deletion since + // contacts cannot be empty. + contacts, err := client.Contacts(context.Background()) + if err != nil { + return fmt.Errorf("expected contacts to still exist") + } + + return checkContacts(contacts, expectedContacts) + } + return nil +} + +func checkContacts(contacts *tailscale.Contacts, expectedContacts *tailscale.Contacts) error { + if contacts.Account.Email != expectedContacts.Account.Email { + return fmt.Errorf("bad account email, expected %q, got %q", expectedContacts.Account.Email, contacts.Account.Email) + } + + if contacts.Support.Email != expectedContacts.Support.Email { + return fmt.Errorf("bad support email, expected %q, got %q", expectedContacts.Support.Email, contacts.Support.Email) + } + + if contacts.Security.Email != expectedContacts.Security.Email { + return fmt.Errorf("bad security email, expected %q, got %q", expectedContacts.Security.Email, contacts.Security.Email) + } + + return nil +}