diff --git a/go.mod b/go.mod index 92368b09..ec9955b7 100644 --- a/go.mod +++ b/go.mod @@ -32,6 +32,6 @@ require ( golang.org/x/sys v0.0.0-20200622214017-ed371f2e16b4 // indirect golang.org/x/text v0.3.3 // indirect golang.org/x/tools v0.0.0-20200622203043-20e05c1c8ffa // indirect - google.golang.org/genproto v0.0.0-20200622133129-d0ee0c36e670 // indirect + google.golang.org/genproto v0.0.0-20200623002339-fbb79eadd5eb // indirect google.golang.org/grpc v1.30.0 // indirect ) diff --git a/go.sum b/go.sum index 798093aa..cd5d27ac 100644 --- a/go.sum +++ b/go.sum @@ -1671,8 +1671,8 @@ google.golang.org/genproto v0.0.0-20200608115520-7c474a2e3482/go.mod h1:jDfRM7Fc google.golang.org/genproto v0.0.0-20200610104632-a5b850bcf112 h1:iwoQI4kCHAgRg0oltV6+Jnq5COzoS0NN+QLqHewrf5U= google.golang.org/genproto v0.0.0-20200610104632-a5b850bcf112/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= -google.golang.org/genproto v0.0.0-20200622133129-d0ee0c36e670 h1:v/N9fZIfu6jopNImrZwgx48fql5yT3k82CJvpIyGtPA= -google.golang.org/genproto v0.0.0-20200622133129-d0ee0c36e670/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200623002339-fbb79eadd5eb h1:PUcq6RTy8Gp9xukBme8m2+2Z8pQCmJ7TbPpQd6xNDvk= +google.golang.org/genproto v0.0.0-20200623002339-fbb79eadd5eb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio= diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 08e066b4..97e3976c 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -24,6 +24,7 @@ func New() terraform.ResourceProvider { }, }, ResourcesMap: map[string]*schema.Resource{ + "watchtower_group": resourceGroup(), "watchtower_host_catalog": resourceHostCatalog(), "watchtower_project": resourceProject(), "watchtower_user": resourceUser(), diff --git a/internal/provider/resource_group.go b/internal/provider/resource_group.go new file mode 100644 index 00000000..d97ea78a --- /dev/null +++ b/internal/provider/resource_group.go @@ -0,0 +1,172 @@ +package provider + +import ( + "fmt" + + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" + "github.com/hashicorp/watchtower/api/groups" + "github.com/hashicorp/watchtower/api/scopes" +) + +const ( + groupNameKey = "name" + groupDescriptionKey = "description" +) + +func resourceGroup() *schema.Resource { + return &schema.Resource{ + Create: resourceGroupCreate, + Read: resourceGroupRead, + Update: resourceGroupUpdate, + Delete: resourceGroupDelete, + Schema: map[string]*schema.Schema{ + groupNameKey: { + Type: schema.TypeString, + Optional: true, + }, + groupDescriptionKey: { + Type: schema.TypeString, + Optional: true, + }, + }, + } + +} + +// convertGroupToResourceData creates a ResourceData type from a Group +func convertGroupToResourceData(u *groups.Group, d *schema.ResourceData) error { + if u.Name != nil { + if err := d.Set(groupNameKey, u.Name); err != nil { + return err + } + } + + if u.Description != nil { + if err := d.Set(groupDescriptionKey, u.Description); err != nil { + return err + } + } + + d.SetId(u.Id) + + return nil +} + +// convertResourceDataToGroup returns a localy built Group using the values provided in the ResourceData. +func convertResourceDataToGroup(d *schema.ResourceData) *groups.Group { + u := &groups.Group{} + if descVal, ok := d.GetOk(groupDescriptionKey); ok { + desc := descVal.(string) + u.Description = &desc + } + if nameVal, ok := d.GetOk(groupNameKey); ok { + name := nameVal.(string) + u.Name = &name + } + + if d.Id() != "" { + u.Id = d.Id() + } + + return u +} + +func resourceGroupCreate(d *schema.ResourceData, meta interface{}) error { + md := meta.(*metaData) + client := md.client + ctx := md.ctx + + o := &scopes.Organization{ + Client: client, + } + + u := convertResourceDataToGroup(d) + + u, apiErr, err := o.CreateGroup(ctx, u) + if err != nil { + return fmt.Errorf("error calling new group: %s", err.Error()) + } + if apiErr != nil { + return fmt.Errorf("error creating group: %s", *apiErr.Message) + } + + d.SetId(u.Id) + + return nil +} + +func resourceGroupRead(d *schema.ResourceData, meta interface{}) error { + md := meta.(*metaData) + client := md.client + ctx := md.ctx + + o := &scopes.Organization{ + Client: client, + } + + u := convertResourceDataToGroup(d) + + u, apiErr, err := o.ReadGroup(ctx, u) + if err != nil { + return fmt.Errorf("error reading group: %s", err.Error()) + } + if apiErr != nil { + return fmt.Errorf("error reading group: %s", *apiErr.Message) + } + + return convertGroupToResourceData(u, d) +} + +func resourceGroupUpdate(d *schema.ResourceData, meta interface{}) error { + md := meta.(*metaData) + client := md.client + ctx := md.ctx + + o := &scopes.Organization{ + Client: client, + } + + u := convertResourceDataToGroup(d) + + if d.HasChange(groupNameKey) { + n := d.Get(groupNameKey).(string) + u.Name = &n + } + + if d.HasChange(groupDescriptionKey) { + d := d.Get(groupDescriptionKey).(string) + u.Description = &d + } + + u, apiErr, err := o.UpdateGroup(ctx, u) + if err != nil { + return err + } + if apiErr != nil { + return fmt.Errorf("error updating group: %s\n Invalid request fields: %v\n", *apiErr.Message, apiErr.Details.RequestFields) + } + + return convertGroupToResourceData(u, d) +} + +func resourceGroupDelete(d *schema.ResourceData, meta interface{}) error { + md := meta.(*metaData) + client := md.client + ctx := md.ctx + + o := &scopes.Organization{ + Client: client, + } + + u := convertResourceDataToGroup(d) + + _, apiErr, err := o.DeleteGroup(ctx, u) + if err != nil { + return fmt.Errorf("error deleting group: %s", err.Error()) + } + if apiErr != nil { + return fmt.Errorf("error deleting group: %s", *apiErr.Message) + } + + return nil +} diff --git a/internal/provider/resource_group_test.go b/internal/provider/resource_group_test.go new file mode 100644 index 00000000..f0fc22d3 --- /dev/null +++ b/internal/provider/resource_group_test.go @@ -0,0 +1,170 @@ +package provider + +import ( + "errors" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/terraform" + "github.com/hashicorp/watchtower/api/groups" + "github.com/hashicorp/watchtower/api/scopes" + "github.com/hashicorp/watchtower/testing/controller" +) + +const ( + fooGroupDescription = "bar" + fooGroupDescriptionUpdate = "foo bar" +) + +var ( + fooGroup = fmt.Sprintf(` +resource "watchtower_group" "foo" { + name = "test" + description = "%s" +}`, fooGroupDescription) + + fooGroupUpdate = fmt.Sprintf(` +resource "watchtower_group" "foo" { + name = "test" + description = "%s" +}`, fooGroupDescriptionUpdate) +) + +func TestAccGroup(t *testing.T) { + tc := controller.NewTestController(t, controller.WithDefaultOrgId("o_0000000000")) + defer tc.Shutdown() + url := tc.ApiAddrs()[0] + + resource.Test(t, resource.TestCase{ + Providers: testProviders, + CheckDestroy: testAccCheckGroupResourceDestroy(t), + Steps: []resource.TestStep{ + { + // test create + Config: testConfig(url, fooGroup), + Check: resource.ComposeTestCheckFunc( + testAccCheckGroupResourceExists("watchtower_group.foo"), + resource.TestCheckResourceAttr("watchtower_group.foo", groupDescriptionKey, fooGroupDescription), + resource.TestCheckResourceAttr("watchtower_group.foo", groupNameKey, "test"), + ), + }, + { + // test update + Config: testConfig(url, fooGroupUpdate), + Check: resource.ComposeTestCheckFunc( + testAccCheckGroupResourceExists("watchtower_group.foo"), + resource.TestCheckResourceAttr("watchtower_group.foo", groupDescriptionKey, fooGroupDescriptionUpdate), + ), + }, + { + // test destroy + Config: testConfig(url), + Check: resource.ComposeTestCheckFunc( + testAccCheckGroupDestroyed("watchtower_group.foo"), + ), + }, + }, + }) +} + +// testAccCheckGroupDestroyed checks the terraform state for the host +// catalog and returns an error if found. +// +// TODO(malnick) This method falls short of checking the Watchtower API for +// the resource if the resource is not found in state. This is due to us not +// having the host catalog ID, but it doesn't guarantee that the resource was +// successfully removed. +// +// It does check Watchtower if the resource is found in state to point out any +// misalignment between what is in state and the actual configuration. +func testAccCheckGroupDestroyed(name string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[name] + if !ok { + // If it's not in state, it's destroyed in TF but not guaranteed to be destroyed + // in Watchtower. Need to find a way to get the host catalog ID here so we can + // form a lookup to the WT API to check this. + return nil + } + errs := []string{} + errs = append(errs, fmt.Sprintf("Found group resource in state: %s", name)) + + id := rs.Primary.ID + if id == "" { + return fmt.Errorf("No ID is set") + } + + md := testProvider.Meta().(*metaData) + + u := groups.Group{Id: id} + + o := &scopes.Organization{ + Client: md.client, + } + if _, apiErr, _ := o.ReadGroup(md.ctx, &u); apiErr == nil || *apiErr.Status != http.StatusNotFound { + errs = append(errs, fmt.Sprintf("Group not destroyed %q: %v", id, apiErr)) + } + + return errors.New(strings.Join(errs, ",")) + } +} + +func testAccCheckGroupResourceExists(name string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[name] + if !ok { + return fmt.Errorf("Not found: %s", name) + } + + id := rs.Primary.ID + if id == "" { + return fmt.Errorf("No ID is set") + } + + md := testProvider.Meta().(*metaData) + + u := groups.Group{Id: id} + + o := &scopes.Organization{ + Client: md.client, + } + if _, _, err := o.ReadGroup(md.ctx, &u); err != nil { + return fmt.Errorf("Got an error when reading group %q: %v", id, err) + } + + return nil + } +} + +func testAccCheckGroupResourceDestroy(t *testing.T) resource.TestCheckFunc { + return func(s *terraform.State) error { + md := testProvider.Meta().(*metaData) + client := md.client + + for _, rs := range s.RootModule().Resources { + switch rs.Type { + case "watchtower_group": + + id := rs.Primary.ID + + u := groups.Group{Id: id} + + o := &scopes.Organization{ + Client: client, + } + + _, apiErr, _ := o.ReadGroup(md.ctx, &u) + if apiErr == nil || *apiErr.Status != http.StatusNotFound { + return fmt.Errorf("Didn't get a 404 when reading destroyed group %q: %v", id, apiErr) + } + + default: + t.Logf("Got unknown resource type %q", rs.Type) + } + } + return nil + } +} diff --git a/website/docs/r/resource_group.html.markdown b/website/docs/r/resource_group.html.markdown new file mode 100644 index 00000000..060ac04e --- /dev/null +++ b/website/docs/r/resource_group.html.markdown @@ -0,0 +1,26 @@ +--- +layout: "watchtower" +page_title: "Watchtower: group_resource" +sidebar_current: "docs-watchtower-group-resource" +description: |- + Group resource for the Watchtower Terraform provider. +--- + +# watchtower_group_resource +The group resource allows you to configure a Watchtower group. + +## Example Usage + +```hcl +resource "watchtower_group" "example" { + name = "My group" + description = "My first group!" +} +``` + +## Argument Reference + +The following arguments are optional: +* `name` - The group name. Defaults to the resource name. +* `description` - The group description. +