diff --git a/docs/data-sources/group.md b/docs/data-sources/group.md new file mode 100644 index 000000000..ff6296b24 --- /dev/null +++ b/docs/data-sources/group.md @@ -0,0 +1,31 @@ +--- +page_title: "hcp_group Data Source - terraform-provider-hcp" +subcategory: "Cloud Platform" +description: |- + The group data source retrieves the given group. +--- + +# hcp_group (Data Source) + +The group data source retrieves the given group. + +## Example Usage + +```terraform +data "hcp_group" "example" { + resource_name = var.resource_name +} +``` + + +## Schema + +### Required + +- `resource_name` (String) The group's resource name in format `iam/organization//group/` or shortened `` + +### Read-Only + +- `description` (String) The group's description +- `display_name` (String) The group's display name +- `resource_id` (String) The group's unique identifier \ No newline at end of file diff --git a/docs/resources/group.md b/docs/resources/group.md new file mode 100644 index 000000000..3b6878dff --- /dev/null +++ b/docs/resources/group.md @@ -0,0 +1,47 @@ +--- +page_title: "Resource hcp_group - terraform-provider-hcp" +subcategory: "Cloud Platform" +description: |- + The group resource manages a HCP Group. + The user or service account that is running Terraform when creating an hcp_group resource must have roles/admin on the parent resource; either the project or organization. +--- + +# hcp_group (Resource) + +The group resource manages a HCP Group. + +The user or service account that is running Terraform when creating an `hcp_group` resource must have `roles/admin` on the parent resource; either the project or organization. + +## Example Usage + +```terraform +resource "hcp_group" "example" { + display_name = "example-group" + description = "My new group!" +} +``` + + +## Schema + +### Required + +- `display_name` (String) The group's display_name - maximum length of 50 characters + +### Optional + +- `description` (String) The group's description - maximum length of 300 characters + +### Read-Only + +- `resource_id` (String) The group's unique identifier +- `resource_name` (String) The group's resource name in the format `iam/organization//group/` + +## Import + +Import is supported using the following syntax: + +```shell +# Group can be imported by specifying the group display name +terraform import hcp_group.example my-test-group +``` diff --git a/examples/data-sources/hcp_group/data-source.tf b/examples/data-sources/hcp_group/data-source.tf new file mode 100644 index 000000000..658db3883 --- /dev/null +++ b/examples/data-sources/hcp_group/data-source.tf @@ -0,0 +1,3 @@ +data "hcp_group" "example" { + resource_name = var.resource_name +} diff --git a/examples/resources/hcp_group/import.sh b/examples/resources/hcp_group/import.sh new file mode 100644 index 000000000..82ca19988 --- /dev/null +++ b/examples/resources/hcp_group/import.sh @@ -0,0 +1,2 @@ +# Group can be imported by specifying the group display name +terraform import hcp_group.example my-test-group \ No newline at end of file diff --git a/examples/resources/hcp_group/resource.tf b/examples/resources/hcp_group/resource.tf new file mode 100644 index 000000000..3701613ac --- /dev/null +++ b/examples/resources/hcp_group/resource.tf @@ -0,0 +1,4 @@ +resource "hcp_group" "example" { + display_name = "example-group" + description = "My new group!" +} \ No newline at end of file diff --git a/internal/provider/iam/data_group_test.go b/internal/provider/iam/data_group_test.go index 6835ef6ef..3693e1603 100644 --- a/internal/provider/iam/data_group_test.go +++ b/internal/provider/iam/data_group_test.go @@ -8,15 +8,17 @@ import ( "github.com/hashicorp/terraform-provider-hcp/internal/provider/acctest" ) -// TODO : update the below tests to use a created group resource via Terraform func TestAccGroupDataSource(t *testing.T) { dataSourceAddress := "data.hcp_group.test" + groupName := acctest.RandString(16) + description := acctest.RandString(64) resource.Test(t, resource.TestCase{ ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories, PreCheck: func() { acctest.PreCheck(t) }, + CheckDestroy: testAccCheckGroupDestroy(t, groupName), Steps: []resource.TestStep{ { - Config: testAccGroupConfig("int-tooling-e2e-test-group"), + Config: testAccGroupConfig(groupName, description), Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttrSet(dataSourceAddress, "resource_id"), resource.TestCheckResourceAttrSet(dataSourceAddress, "description"), @@ -27,28 +29,14 @@ func TestAccGroupDataSource(t *testing.T) { }) } -func TestAccGroupDataSourceFullResourceName(t *testing.T) { - dataSourceAddress := "data.hcp_group.test" - resource.Test(t, resource.TestCase{ - ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories, - PreCheck: func() { acctest.PreCheck(t) }, - Steps: []resource.TestStep{ - { - Config: testAccGroupConfig("iam/organization/d11d7309-5072-44f9-aaea-c8f37c09a8b5/group/int-tooling-e2e-test-group"), - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttrSet(dataSourceAddress, "resource_id"), - resource.TestCheckResourceAttrSet(dataSourceAddress, "description"), - resource.TestCheckResourceAttrSet(dataSourceAddress, "display_name"), - ), - }, - }, - }) -} - -func testAccGroupConfig(resourceName string) string { +func testAccGroupConfig(name, description string) string { return fmt.Sprintf(` + resource "hcp_group" "test" { + display_name = %q + description = %q + } data "hcp_group" "test" { - resource_name = %q + resource_name = hcp_group.test.resource_name } -`, resourceName) +`, name, description) } diff --git a/internal/provider/iam/resource_group.go b/internal/provider/iam/resource_group.go new file mode 100644 index 000000000..d96421230 --- /dev/null +++ b/internal/provider/iam/resource_group.go @@ -0,0 +1,225 @@ +package iam + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/hashicorp/hcp-sdk-go/clients/cloud-iam/stable/2019-12-10/client/groups_service" + "github.com/hashicorp/hcp-sdk-go/clients/cloud-iam/stable/2019-12-10/models" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + clients "github.com/hashicorp/terraform-provider-hcp/internal/clients" +) + +func NewGroupResource() resource.Resource { + return &resourceGroup{} +} + +type resourceGroup struct { + client *clients.Client +} + +func (r *resourceGroup) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_group" +} + +func (r *resourceGroup) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: fmt.Sprintf(`The group resource manages a HCP Group. + +The user or service account that is running Terraform when creating an %s resource must have %s on the parent resource; either the project or organization.`, + "`hcp_group`", "`roles/admin`"), + Attributes: map[string]schema.Attribute{ + "resource_id": schema.StringAttribute{ + Computed: true, + Description: "The group's unique identifier", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "resource_name": schema.StringAttribute{ + Computed: true, + Description: fmt.Sprintf("The group's resource name in the format `%s`", + "iam/organization//group/"), + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "display_name": schema.StringAttribute{ + Required: true, + Description: "The group's display_name - maximum length of 50 characters", + Validators: []validator.String{ + stringvalidator.LengthBetween(1, 50), + }, + }, + "description": schema.StringAttribute{ + Description: "The group's description - maximum length of 300 characters", + Optional: true, + Validators: []validator.String{ + stringvalidator.LengthBetween(0, 300), + }, + }, + }, + } +} + +func (r *resourceGroup) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + client, ok := req.ProviderData.(*clients.Client) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected *clients.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + r.client = client +} + +type Group struct { + ResourceID types.String `tfsdk:"resource_id"` + ResourceName types.String `tfsdk:"resource_name"` + DisplayName types.String `tfsdk:"display_name"` + Description types.String `tfsdk:"description"` +} + +func (r *resourceGroup) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan Group + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + orgID := r.client.Config.OrganizationID + parent := fmt.Sprintf("organization/%s", orgID) + + createParams := groups_service.NewGroupsServiceCreateGroupParams().WithContext(ctx) + createParams.ParentResourceName = parent + createParams.Body = groups_service.GroupsServiceCreateGroupBody{ + Name: plan.DisplayName.ValueString(), + Description: plan.Description.ValueString(), + } + + res, err := r.client.Groups.GroupsServiceCreateGroup(createParams, nil) + + if err != nil { + resp.Diagnostics.AddError("Error creating group", err.Error()) + return + } + + group := res.GetPayload().Group + + plan.ResourceID = types.StringValue(group.ResourceID) + plan.ResourceName = types.StringValue(group.ResourceName) + plan.DisplayName = types.StringValue(group.DisplayName) + plan.Description = types.StringValue(group.Description) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) + +} + +func (r *resourceGroup) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state Group + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + getParams := groups_service.NewGroupsServiceGetGroupParams().WithContext(ctx) + getParams.ResourceName = state.ResourceName.ValueString() + res, err := r.client.Groups.GroupsServiceGetGroup(getParams, nil) + + if err != nil { + var getErr *groups_service.GroupsServiceGetGroupDefault + if errors.As(err, &getErr) && getErr.IsCode(http.StatusNotFound) { + resp.State.RemoveResource(ctx) + return + } + + resp.Diagnostics.AddError("Error retrieving group", err.Error()) + return + } + + group := res.GetPayload().Group + + state.ResourceID = types.StringValue(group.ResourceID) + state.ResourceName = types.StringValue(group.ResourceName) + state.DisplayName = types.StringValue(group.DisplayName) + state.Description = types.StringValue(group.Description) + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *resourceGroup) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state Group + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + updateParams := groups_service.NewGroupsServiceUpdateGroupParams().WithContext(ctx) + updateParams.ResourceName = state.ResourceName.ValueString() + updateParams.Body = groups_service.GroupsServiceUpdateGroupBody{Group: &models.HashicorpCloudIamGroup{ResourceName: state.ResourceName.ValueString(), ResourceID: state.ResourceID.ValueString()}} + paths := []string{} + + // Check if the display name was updated + if !plan.DisplayName.Equal(state.DisplayName) { + updateParams.Body.Group.DisplayName = plan.DisplayName.ValueString() + paths = append(paths, "displayName") + } + + // Check if the description was updated + if !plan.Description.Equal(state.Description) { + updateParams.Body.Group.Description = plan.Description.ValueString() + paths = append(paths, "description") + } + + mask := strings.Join(paths, `,`) + updateParams.Body.UpdateMask = mask + _, err := r.client.Groups.GroupsServiceUpdateGroup(updateParams, nil) + + if err != nil { + resp.Diagnostics.AddError("Error updating group", err.Error()) + return + } + + // Store the updated values + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *resourceGroup) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state Group + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + deleteParams := groups_service.NewGroupsServiceDeleteGroupParams().WithContext(ctx) + deleteParams.ResourceName = state.ResourceName.ValueString() + _, err := r.client.Groups.GroupsServiceDeleteGroup(deleteParams, nil) + + if err != nil { + var getErr *groups_service.GroupsServiceDeleteGroupDefault + if errors.As(err, &getErr) && getErr.IsCode(http.StatusNotFound) { + resp.State.RemoveResource(ctx) + return + } + + resp.Diagnostics.AddError("Error deleting group", err.Error()) + return + } +} + +func (r *resourceGroup) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("resource_name"), req, resp) +} diff --git a/internal/provider/iam/resource_group_test.go b/internal/provider/iam/resource_group_test.go new file mode 100644 index 000000000..d3f95c8d8 --- /dev/null +++ b/internal/provider/iam/resource_group_test.go @@ -0,0 +1,141 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package iam_test + +import ( + "errors" + "fmt" + "net/http" + "testing" + + "github.com/hashicorp/hcp-sdk-go/clients/cloud-iam/stable/2019-12-10/client/groups_service" + "github.com/hashicorp/hcp-sdk-go/clients/cloud-iam/stable/2019-12-10/models" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/plancheck" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/hashicorp/terraform-provider-hcp/internal/provider/acctest" +) + +func TestAccGroupResource(t *testing.T) { + groupName := acctest.RandString(16) + description := acctest.RandString(8) + + groupNameUpdated := acctest.RandString(16) + descriptionUpdated := acctest.RandString(200) + var group models.HashicorpCloudIamGroup + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories, + PreCheck: func() { acctest.PreCheck(t) }, + CheckDestroy: testAccCheckGroupDestroy(t, groupName), + Steps: []resource.TestStep{ + { + Config: testAccGroupConfigResource(groupName, description), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("hcp_group.example", "display_name", groupName), + resource.TestCheckResourceAttr("hcp_group.example", "description", description), + resource.TestCheckResourceAttrSet("hcp_group.example", "resource_name"), + resource.TestCheckResourceAttrSet("hcp_group.example", "resource_id"), + testAccGroupExists(t, "hcp_group.example", &group), + ), + }, + { + ResourceName: "hcp_group.example", + ImportState: true, + ImportStateVerifyIdentifierAttribute: "resource_name", + ImportStateIdFunc: testAccGroupImportID, + ImportStateVerify: true, + }, + { + // Update the name/description + Config: testAccGroupConfigResource(groupNameUpdated, descriptionUpdated), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("hcp_group.example", plancheck.ResourceActionUpdate), + }, + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("hcp_group.example", "display_name", groupNameUpdated), + resource.TestCheckResourceAttr("hcp_group.example", "description", descriptionUpdated), + resource.TestCheckResourceAttrSet("hcp_group.example", "resource_name"), + resource.TestCheckResourceAttrSet("hcp_group.example", "resource_id"), + ), + }, + }, + }) +} + +func testAccGroupConfigResource(displayName, description string) string { + return fmt.Sprintf(` + resource "hcp_group" "example" { + display_name = %q + description = %q + } +`, displayName, description) +} + +// testAccGroupsExists queries the API and retrieves the matching +// group. +func testAccGroupExists(t *testing.T, resourceName string, sp *models.HashicorpCloudIamGroup) resource.TestCheckFunc { + return func(s *terraform.State) error { + // find the corresponding state object + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("Not found: %s", resourceName) + } + + // Get the group resource name from state + rname := rs.Primary.Attributes["resource_name"] + + // Fetch the group + client := acctest.HCPClients(t) + getParams := groups_service.NewGroupsServiceGetGroupParams() + getParams.ResourceName = rname + res, err := client.Groups.GroupsServiceGetGroup(getParams, nil) + if err != nil { + return err + } + + if res.GetPayload().Group == nil { + return fmt.Errorf("Group (%s) not found", rname) + } + + // assign the response project to the pointer + *sp = *res.GetPayload().Group + return nil + } +} + +// testAccGroupImportID retrieves the resource_name so that it can be imported. +func testAccGroupImportID(s *terraform.State) (string, error) { + rs, ok := s.RootModule().Resources["hcp_group.example"] + if !ok { + return "", fmt.Errorf("resource not found") + } + + id, ok := rs.Primary.Attributes["resource_name"] + if !ok { + return "", fmt.Errorf("resource_name not set") + } + + return id, nil +} + +func testAccCheckGroupDestroy(t *testing.T, groupName string) resource.TestCheckFunc { + return func(_ *terraform.State) error { + client := acctest.HCPClients(t) + resourceName := fmt.Sprintf("iam/organization/%s/group/%s", client.GetOrganizationID(), groupName) + getParams := groups_service.NewGroupsServiceGetGroupParams().WithResourceName(resourceName) + _, err := client.Groups.GroupsServiceGetGroup(getParams, nil) + if err != nil { + var getErr *groups_service.GroupsServiceGetGroupDefault + if errors.As(err, &getErr) && getErr.IsCode(http.StatusNotFound) { + + return nil + } + + } + return fmt.Errorf("didn't get a 404 when reading destroyed group %s: %v", resourceName, err) + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index b3925e476..6d05ff2c5 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -143,6 +143,7 @@ func (p *ProviderFramework) Resources(ctx context.Context) []func() resource.Res iam.NewServicePrincipalResource, iam.NewServicePrincipalKeyResource, iam.NewWorkloadIdentityProviderResource, + iam.NewGroupResource, // Log Streaming logstreaming.NewHCPLogStreamingDestinationResource, // Webhook diff --git a/templates/data-sources/group.md.tmpl b/templates/data-sources/group.md.tmpl new file mode 100644 index 000000000..87c240930 --- /dev/null +++ b/templates/data-sources/group.md.tmpl @@ -0,0 +1,16 @@ +--- +page_title: "{{.Name}} {{.Type}} - {{.ProviderName}}" +subcategory: "Cloud Platform" +description: |- +{{ .Description | plainmarkdown | trimspace | prefixlines " " }} +--- + +# {{.Name}} ({{.Type}}) + +{{ .Description | trimspace }} + +## Example Usage + +{{ tffile "examples/data-sources/hcp_group/data-source.tf" }} + +{{ .SchemaMarkdown | trimspace }} \ No newline at end of file diff --git a/templates/resources/group.md.tmpl b/templates/resources/group.md.tmpl new file mode 100644 index 000000000..c40ba29b9 --- /dev/null +++ b/templates/resources/group.md.tmpl @@ -0,0 +1,22 @@ +--- +page_title: "{{.Type}} {{.Name}} - {{.ProviderName}}" +subcategory: "Cloud Platform" +description: |- +{{ .Description | plainmarkdown | trimspace | prefixlines " " }} +--- + +# {{.Name}} ({{.Type}}) + +{{ .Description | trimspace }} + +## Example Usage + +{{ tffile "examples/resources/hcp_group/resource.tf" }} + +{{ .SchemaMarkdown | trimspace }} + +## Import + +Import is supported using the following syntax: + +{{ codefile "shell" "examples/resources/hcp_group/import.sh" }}