diff --git a/docs/resources/resource_group.md b/docs/resources/resource_group.md new file mode 100644 index 00000000..e1b84a14 --- /dev/null +++ b/docs/resources/resource_group.md @@ -0,0 +1,62 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "trocco_resource_group Resource - trocco" +subcategory: "" +description: |- + Provides a TROCCO resource_group resource. +--- + +# trocco_resource_group (Resource) + +Provides a TROCCO resource_group resource. + +## Example Usage + +```terraform +resource "trocco_resource_group" "example" { + name = "resource group name" + description = "description" + teams = [ + { + team_id = 1 + role = "administrator" + }, + { + team_id = 2 + role = "operator" + } + ] +} +``` + + +## Schema + +### Required + +- `name` (String) The name of the resource group. +- `teams` (Attributes Set) The team roles of the resource group. (see [below for nested schema](#nestedatt--teams)) + +### Optional + +- `description` (String) The description of the resource group. + +### Read-Only + +- `id` (Number) The ID of the resource group. + + +### Nested Schema for `teams` + +Required: + +- `role` (String) The role of the team. Valid values are `administrator`, `editor`, `operator`, `viewer`. +- `team_id` (Number) The team ID of the role. + +## Import + +Import is supported using the following syntax: + +```shell +terraform import trocco_resource.import_resource_group +``` diff --git a/examples/resources/trocco_resource_group/import.sh b/examples/resources/trocco_resource_group/import.sh new file mode 100644 index 00000000..683e944f --- /dev/null +++ b/examples/resources/trocco_resource_group/import.sh @@ -0,0 +1 @@ +terraform import trocco_resource.import_resource_group diff --git a/examples/resources/trocco_resource_group/resource.tf b/examples/resources/trocco_resource_group/resource.tf new file mode 100644 index 00000000..1c92789d --- /dev/null +++ b/examples/resources/trocco_resource_group/resource.tf @@ -0,0 +1,15 @@ +resource "trocco_resource_group" "example" { + name = "resource group name" + description = "description" + teams = [ + { + team_id = 1 + role = "administrator" + }, + { + team_id = 2 + role = "operator" + } + ] +} + diff --git a/internal/client/resource_group.go b/internal/client/resource_group.go new file mode 100644 index 00000000..a61827b7 --- /dev/null +++ b/internal/client/resource_group.go @@ -0,0 +1,124 @@ +package client + +import ( + "fmt" + "net/http" + "net/url" +) + +const resourceGroupBasePath = "/api/resource_groups" + +type ResourceGroupWithTeams struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description *string `json:"description"` + Teams []ResourceGroupPermission `json:"teams"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type ResourceGroupPermission struct { + TeamID int64 `json:"team_id"` + Role string `json:"role"` +} + +// List of ResourceGroups + +type ListResourceGroupInput struct { + limit *int + cursor *string +} + +func (input *ListResourceGroupInput) SetLimit(limit int) { + input.limit = &limit +} + +func (input *ListResourceGroupInput) SetCursor(cursor string) { + input.cursor = &cursor +} + +type ListResourceGroupOutput struct { + Items []ResourceGroupWithTeams `json:"items"` + NextCursor *string `json:"next_cursor"` +} + +const MaxListResourceGroupsLimit = 100 + +func (client *TroccoClient) ListResourceGroups(input *ListResourceGroupInput) (*ListResourceGroupOutput, error) { + params := url.Values{} + if input != nil && input.limit != nil { + if *input.limit < 1 || *input.limit > MaxListResourceGroupsLimit { + return nil, fmt.Errorf("limit must be between 1 and %d", MaxListResourceGroupsLimit) + } + params.Add("limit", fmt.Sprintf("%d", *input.limit)) + } + if input != nil && input.cursor != nil { + params.Add("cursor", *input.cursor) + } + path := fmt.Sprintf(resourceGroupBasePath+"?%s", params.Encode()) + output := new(ListResourceGroupOutput) + err := client.do(http.MethodGet, path, nil, output) + if err != nil { + return nil, err + } + return output, nil +} + +// Get a Team + +func (client *TroccoClient) GetResourceGroup(id int64) (*ResourceGroupWithTeams, error) { + path := fmt.Sprintf("%s/%d", resourceGroupBasePath, id) + output := new(ResourceGroupWithTeams) + err := client.do(http.MethodGet, path, nil, output) + if err != nil { + return nil, err + } + return output, nil +} + +// Create + +type CreateResourceGroupInput struct { + Name string `json:"name"` + Description *string `json:"description,omitempty"` + Teams []TeamRoleInput `json:"teams"` +} + +type TeamRoleInput struct { + TeamID int64 `json:"team_id"` + Role string `json:"role"` +} + +func (client *TroccoClient) CreateResourceGroup(input *CreateResourceGroupInput) (*ResourceGroupWithTeams, error) { + output := new(ResourceGroupWithTeams) + err := client.do(http.MethodPost, resourceGroupBasePath, input, output) + if err != nil { + return nil, err + } + return output, nil +} + +// Update + +type UpdateResourceGroupInput struct { + Name *string `json:"name"` + Description *string `json:"description,omitempty"` + Teams []TeamRoleInput `json:"teams"` +} + +func (client *TroccoClient) UpdateResourceGroup(id int64, input *UpdateResourceGroupInput) (*ResourceGroupWithTeams, error) { + path := fmt.Sprintf("%s/%d", resourceGroupBasePath, id) + output := new(ResourceGroupWithTeams) + err := client.do(http.MethodPatch, path, input, output) + if err != nil { + return nil, err + } + return output, nil +} + +// Delete + +func (client *TroccoClient) DeleteResourceGroup(id int64) error { + path := fmt.Sprintf("%s/%d", resourceGroupBasePath, id) + return client.do(http.MethodDelete, path, nil, nil) +} diff --git a/internal/client/resource_group_test.go b/internal/client/resource_group_test.go new file mode 100644 index 00000000..85ffca0f --- /dev/null +++ b/internal/client/resource_group_test.go @@ -0,0 +1,278 @@ +package client + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/samber/lo" + "github.com/stretchr/testify/assert" +) + +// List Teams + +func TestListResourceGroup(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/resource_groups", r.URL.Path) + assert.Equal(t, http.MethodGet, r.Method) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + resp := ` + { + "items": [ + { + "id": 1, + "name": "ResourceGroup 1", + "description": "ResourceGroup 1 description", + "created_at": "2023-10-16T18:24:51.806+09:00", + "updated_at": "2023-10-16T18:24:51.806+09:00" + }, + { + "id": 2, + "name": "ResourceGroup 2", + "description": "ResourceGroup 2 description", + "created_at": "2023-10-16T18:24:51.806+09:00", + "updated_at": "2023-10-16T18:24:51.806+09:00" + } + ] + } + ` + _, err := w.Write([]byte(resp)) + assert.NoError(t, err) + })) + defer server.Close() + + output, err := NewDevTroccoClient("1234567890", server.URL).ListResourceGroups(nil) + + assert.NoError(t, err) + assert.Len(t, output.Items, 2) + assert.Equal(t, int64(1), output.Items[0].ID) + assert.Equal(t, "ResourceGroup 1", output.Items[0].Name) + assert.Equal(t, "ResourceGroup 1 description", *output.Items[0].Description) + assert.Equal(t, "2023-10-16T18:24:51.806+09:00", output.Items[0].CreatedAt) + assert.Equal(t, "2023-10-16T18:24:51.806+09:00", output.Items[0].UpdatedAt) + assert.Equal(t, int64(2), output.Items[1].ID) + assert.Equal(t, "ResourceGroup 2", output.Items[1].Name) + assert.Equal(t, "ResourceGroup 2 description", *output.Items[1].Description) + assert.Equal(t, "2023-10-16T18:24:51.806+09:00", output.Items[1].CreatedAt) + assert.Equal(t, "2023-10-16T18:24:51.806+09:00", output.Items[1].UpdatedAt) +} + +func TestListResourceGroupsLimitAndCursor(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "1", r.URL.Query().Get("limit")) + assert.Equal(t, "test_prev_cursor", r.URL.Query().Get("cursor")) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + resp := ` + { + "items": [ + { + "id": 1, + "name": "ResourceGroup 1", + "description": "ResourceGroup 1 description", + "created_at": "2023-10-16T18:24:51.806+09:00", + "updated_at": "2023-10-16T18:24:51.806+09:00" + } + ], + "next_cursor": "test_next_cursor" + } + ` + _, err := w.Write([]byte(resp)) + assert.NoError(t, err) + })) + defer server.Close() + + input := &ListResourceGroupInput{} + input.SetLimit(1) + input.SetCursor("test_prev_cursor") + + output, err := NewDevTroccoClient("1234567890", server.URL).ListResourceGroups(input) + + assert.NoError(t, err) + assert.Len(t, output.Items, 1) + assert.Equal(t, int64(1), output.Items[0].ID) + assert.Equal(t, "ResourceGroup 1", output.Items[0].Name) + assert.Equal(t, "ResourceGroup 1 description", *output.Items[0].Description) + assert.Equal(t, "2023-10-16T18:24:51.806+09:00", output.Items[0].CreatedAt) + assert.Equal(t, "2023-10-16T18:24:51.806+09:00", output.Items[0].UpdatedAt) + assert.Equal(t, "test_next_cursor", *output.NextCursor) +} + +// Get Team + +func TestGetResourceGroup(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/resource_groups/1", r.URL.Path) + assert.Equal(t, http.MethodGet, r.Method) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + resp := ` + { + "id": 1, + "name": "ResourceGroup 1", + "description": "ResourceGroup 1 description", + "created_at": "2023-10-16T18:24:51.806+09:00", + "updated_at": "2023-10-16T18:24:51.806+09:00", + "teams": [ + { + "team_id": 1, + "role": "administrator" + }, + { + "team_id": 2, + "role": "operator" + } + ] + } + ` + _, err := w.Write([]byte(resp)) + assert.NoError(t, err) + })) + defer server.Close() + + output, err := NewDevTroccoClient("1234567890", server.URL).GetResourceGroup(1) + + assert.NoError(t, err) + assert.Equal(t, int64(1), output.ID) + assert.Equal(t, "ResourceGroup 1", output.Name) + assert.Equal(t, "ResourceGroup 1 description", *output.Description) + assert.Equal(t, "2023-10-16T18:24:51.806+09:00", output.CreatedAt) + assert.Equal(t, "2023-10-16T18:24:51.806+09:00", output.UpdatedAt) + assert.Len(t, output.Teams, 2) + assert.Equal(t, int64(1), output.Teams[0].TeamID) + assert.Equal(t, "administrator", output.Teams[0].Role) + assert.Equal(t, int64(2), output.Teams[1].TeamID) + assert.Equal(t, "operator", output.Teams[1].Role) +} + +// Create Team + +func TestCreateResourceGroup(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/resource_groups", r.URL.Path) + assert.Equal(t, http.MethodPost, r.Method) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + + resp := ` + { + "id": 1, + "name": "ResourceGroup", + "description": "description", + "created_at": "2023-10-16T18:24:51.806+09:00", + "updated_at": "2023-10-16T18:24:51.806+09:00", + "teams": [ + { + "team_id": 1, + "role": "administrator" + } + ] + } + ` + _, err := w.Write([]byte(resp)) + assert.NoError(t, err) + })) + defer server.Close() + + input := &CreateResourceGroupInput{ + Name: "ResourceGroup", + Description: lo.ToPtr("description"), + Teams: []TeamRoleInput{ + {TeamID: 1, Role: "administrator"}, + }, + } + + output, err := NewDevTroccoClient("1234567890", server.URL).CreateResourceGroup(input) + + assert.NoError(t, err) + assert.Equal(t, int64(1), output.ID) + assert.Equal(t, "ResourceGroup", output.Name) + assert.Equal(t, "description", *output.Description) + assert.Equal(t, "2023-10-16T18:24:51.806+09:00", output.CreatedAt) + assert.Equal(t, "2023-10-16T18:24:51.806+09:00", output.UpdatedAt) + assert.Len(t, output.Teams, 1) + assert.Equal(t, int64(1), output.Teams[0].TeamID) + assert.Equal(t, "administrator", output.Teams[0].Role) +} + +// Update Team + +func TestUpdateResourceGroup(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/resource_groups/1", r.URL.Path) + assert.Equal(t, http.MethodPatch, r.Method) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + resp := ` + { + "id": 1, + "name": "ResourceGroup", + "description": "description", + "created_at": "2023-10-16T18:24:51.806+09:00", + "updated_at": "2023-10-16T18:24:51.806+09:00", + "teams": [ + { + "team_id": 1, + "role": "administrator" + }, + { + "team_id": 2, + "role": "operator" + } + ] + } + ` + _, err := w.Write([]byte(resp)) + assert.NoError(t, err) + })) + defer server.Close() + + input := &UpdateResourceGroupInput{ + Name: lo.ToPtr("ResourceGroup"), + Description: lo.ToPtr("description"), + Teams: []TeamRoleInput{ + {TeamID: 1, Role: "administrator"}, + {TeamID: 2, Role: "operator"}, + }, + } + + output, err := NewDevTroccoClient("1234567890", server.URL).UpdateResourceGroup(1, input) + + assert.NoError(t, err) + assert.Equal(t, int64(1), output.ID) + assert.Equal(t, "ResourceGroup", output.Name) + assert.Equal(t, "description", *output.Description) + assert.Equal(t, "2023-10-16T18:24:51.806+09:00", output.CreatedAt) + assert.Equal(t, "2023-10-16T18:24:51.806+09:00", output.UpdatedAt) + assert.Len(t, output.Teams, 2) + assert.Equal(t, int64(1), output.Teams[0].TeamID) + assert.Equal(t, "administrator", output.Teams[0].Role) + assert.Equal(t, int64(2), output.Teams[1].TeamID) + assert.Equal(t, "operator", output.Teams[1].Role) +} + +// Delete Team + +func TestDeleteResourceGroup(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/resource_groups/1", r.URL.Path) + assert.Equal(t, http.MethodDelete, r.Method) + + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + err := NewDevTroccoClient("1234567890", server.URL).DeleteResourceGroup(1) + + assert.NoError(t, err) +} diff --git a/internal/provider/model/resource_group/resource_group.go b/internal/provider/model/resource_group/resource_group.go new file mode 100644 index 00000000..d0ea6a6b --- /dev/null +++ b/internal/provider/model/resource_group/resource_group.go @@ -0,0 +1,17 @@ +package resource_group + +import ( + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type ResourceGroupResourceModel struct { + ID types.Int64 `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + Teams []TeamRoleResourceModel `tfsdk:"teams"` +} + +type TeamRoleResourceModel struct { + TeamID types.Int64 `tfsdk:"team_id"` + Role types.String `tfsdk:"role"` +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index a8d274ae..b8b44753 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -135,6 +135,7 @@ func (p *TroccoProvider) Resources(ctx context.Context) []func() resource.Resour NewPipelineDefinitionResource, NewJobDefinitionResource, NewTeamResource, + NewResourceGroupResource, } } diff --git a/internal/provider/resource_group_resource.go b/internal/provider/resource_group_resource.go new file mode 100644 index 00000000..da58c889 --- /dev/null +++ b/internal/provider/resource_group_resource.go @@ -0,0 +1,256 @@ +package provider + +import ( + "context" + "fmt" + "strconv" + "terraform-provider-trocco/internal/client" + model "terraform-provider-trocco/internal/provider/model/resource_group" + troccoValidator "terraform-provider-trocco/internal/provider/validator" + + "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/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ resource.Resource = &resourceGroupResource{} + _ resource.ResourceWithConfigure = &resourceGroupResource{} + _ resource.ResourceWithImportState = &resourceGroupResource{} +) + +func NewResourceGroupResource() resource.Resource { + return &resourceGroupResource{} +} + +type resourceGroupResource struct { + client *client.TroccoClient +} + +func (r *resourceGroupResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_resource_group" +} + +func (r *resourceGroupResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*client.TroccoClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *client.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + r.client = client +} + +func (r *resourceGroupResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Provides a TROCCO resource_group resource.", + Attributes: map[string]schema.Attribute{ + "id": schema.Int64Attribute{ + Computed: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + MarkdownDescription: "The ID of the resource group.", + }, + "name": schema.StringAttribute{ + Required: true, + MarkdownDescription: "The name of the resource group.", + Validators: []validator.String{ + stringvalidator.UTF8LengthAtLeast(1), + stringvalidator.UTF8LengthAtMost(255), + }, + }, + "description": schema.StringAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "The description of the resource group.", + Default: stringdefault.StaticString(""), + }, + "teams": schema.SetNestedAttribute{ + Required: true, + MarkdownDescription: "The team roles of the resource group.", + Validators: []validator.Set{ + troccoValidator.UniqueTeamValidator{}, + }, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "team_id": schema.Int64Attribute{ + Required: true, + MarkdownDescription: "The team ID of the role.", + }, + "role": schema.StringAttribute{ + Required: true, + Validators: []validator.String{ + stringvalidator.OneOf("administrator", "editor", "operator", "viewer"), + }, + MarkdownDescription: "The role of the team. Valid values are `administrator`, `editor`, `operator`, `viewer`.", + }, + }, + }, + }, + }, + } +} + +func (r *resourceGroupResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan model.ResourceGroupResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + input := client.CreateResourceGroupInput{ + Name: plan.Name.ValueString(), + Description: plan.Description.ValueStringPointer(), + Teams: []client.TeamRoleInput{}, + } + for _, m := range plan.Teams { + input.Teams = append(input.Teams, client.TeamRoleInput{ + TeamID: m.TeamID.ValueInt64(), + Role: m.Role.ValueString(), + }) + + } + + resourceGroup, err := r.client.CreateResourceGroup(&input) + if err != nil { + resp.Diagnostics.AddError( + "Creating resource group", + fmt.Sprintf("Unable to create resource group, got error: %s", err), + ) + return + } + + newState := model.ResourceGroupResourceModel{ + ID: types.Int64Value(resourceGroup.ID), + Name: types.StringValue(resourceGroup.Name), + Description: types.StringPointerValue(resourceGroup.Description), + Teams: []model.TeamRoleResourceModel{}, + } + for _, m := range resourceGroup.Teams { + newState.Teams = append(newState.Teams, model.TeamRoleResourceModel{ + TeamID: types.Int64Value(m.TeamID), + Role: types.StringValue(m.Role), + }) + } + + resp.Diagnostics.Append(resp.State.Set(ctx, newState)...) +} + +func (r *resourceGroupResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state model.ResourceGroupResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + resourceGroup, err := r.client.GetResourceGroup(state.ID.ValueInt64()) + if err != nil { + resp.Diagnostics.AddError( + "Reading resource group", + fmt.Sprintf("Unable to read resource group, got error: %s", err), + ) + return + } + + newState := model.ResourceGroupResourceModel{ + ID: types.Int64Value(resourceGroup.ID), + Name: types.StringValue(resourceGroup.Name), + Description: types.StringPointerValue(resourceGroup.Description), + Teams: []model.TeamRoleResourceModel{}, + } + for _, m := range resourceGroup.Teams { + newState.Teams = append(newState.Teams, model.TeamRoleResourceModel{ + TeamID: types.Int64Value(m.TeamID), + Role: types.StringValue(m.Role), + }) + } + + resp.Diagnostics.Append(resp.State.Set(ctx, newState)...) +} +func (r *resourceGroupResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state model.ResourceGroupResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + input := client.UpdateResourceGroupInput{ + Name: plan.Name.ValueStringPointer(), + Description: plan.Description.ValueStringPointer(), + Teams: []client.TeamRoleInput{}, + } + for _, m := range plan.Teams { + input.Teams = append(input.Teams, client.TeamRoleInput{ + TeamID: m.TeamID.ValueInt64(), + Role: m.Role.ValueString(), + }) + } + + resourceGroup, err := r.client.UpdateResourceGroup(state.ID.ValueInt64(), &input) + if err != nil { + resp.Diagnostics.AddError( + "Updating team", + fmt.Sprintf("Unable to update team, got error: %s", err), + ) + return + } + + newState := model.ResourceGroupResourceModel{ + ID: types.Int64Value(resourceGroup.ID), + Name: types.StringValue(resourceGroup.Name), + Description: types.StringPointerValue(resourceGroup.Description), + Teams: []model.TeamRoleResourceModel{}, + } + for _, m := range resourceGroup.Teams { + newState.Teams = append(newState.Teams, model.TeamRoleResourceModel{ + TeamID: types.Int64Value(m.TeamID), + Role: types.StringValue(m.Role), + }) + } + + resp.Diagnostics.Append(resp.State.Set(ctx, newState)...) +} +func (r *resourceGroupResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state model.ResourceGroupResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + err := r.client.DeleteResourceGroup(state.ID.ValueInt64()) + if err != nil { + resp.Diagnostics.AddError( + "Deleting resource group", + fmt.Sprintf("Unable to delete resource group, got error: %s", err), + ) + return + } +} + +func (r *resourceGroupResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + id, err := strconv.ParseInt(req.ID, 10, 64) + if err != nil { + resp.Diagnostics.AddError( + "Importing resource group", + fmt.Sprintf("Unable to parse id, got error: %s", err), + ) + return + } + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), id)...) +} diff --git a/internal/provider/resource_group_resource_test.go b/internal/provider/resource_group_resource_test.go new file mode 100644 index 00000000..3f9dfce8 --- /dev/null +++ b/internal/provider/resource_group_resource_test.go @@ -0,0 +1,117 @@ +package provider + +import ( + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAccResourceGroupResource(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Create and Read testing + { + Config: providerConfig + ` + resource "trocco_resource_group" "test" { + name = "test" + description = "test" + teams = [ + { + team_id = 1 + role = "operator" + } + ] + } + `, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("trocco_resource_group.test", "name", "test"), + resource.TestCheckResourceAttr("trocco_resource_group.test", "description", "test"), + resource.TestCheckResourceAttr("trocco_resource_group.test", "teams.#", "1"), + resource.TestCheckResourceAttr("trocco_resource_group.test", "teams.0.team_id", "1"), + resource.TestCheckResourceAttr("trocco_resource_group.test", "teams.0.role", "operator"), + ), + }, + // ImportState testing + { + ResourceName: "trocco_resource_group.test", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"id"}, + }, + // Update testing + { + Config: providerConfig + ` + resource "trocco_resource_group" "test" { + name = "updated" + description = "updated" + teams = [ + { + team_id = 1 + role = "administrator" + }, + { + team_id = 2 + role = "operator" + } + ] + } + `, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("trocco_resource_group.test", "name", "updated"), + resource.TestCheckResourceAttr("trocco_resource_group.test", "description", "updated"), + resource.TestCheckResourceAttr("trocco_resource_group.test", "teams.#", "2"), + resource.TestCheckResourceAttr("trocco_resource_group.test", "teams.0.team_id", "1"), + resource.TestCheckResourceAttr("trocco_resource_group.test", "teams.0.role", "administrator"), + resource.TestCheckResourceAttr("trocco_resource_group.test", "teams.1.team_id", "2"), + resource.TestCheckResourceAttr("trocco_resource_group.test", "teams.1.role", "operator"), + ), + }, + }, + }) +} + +func TestAccResourceGroupNoTeams(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: providerConfig + ` + resource "trocco_resource_group" "test" { + name = "test" + description = "test" + teams = [] + } + `, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("trocco_resource_group.test", "name", "test"), + resource.TestCheckResourceAttr("trocco_resource_group.test", "description", "test"), + resource.TestCheckResourceAttr("trocco_resource_group.test", "teams.#", "0"), + ), + }, + }, + }) +} + +func TestAccResourceGroupDuplicateRoles(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: providerConfig + ` + resource "trocco_resource_group" "test" { + name = "test" + description = "test" + teams = [ + { team_id = 1, role = "administrator" }, + { team_id = 1, role = "operator" }, + ] + } + `, + ExpectError: regexp.MustCompile(`Team ID "1" is duplicated in the list.`), + }, + }, + }) + +} diff --git a/internal/provider/validator/unique_team_validator.go b/internal/provider/validator/unique_team_validator.go new file mode 100644 index 00000000..8e328f38 --- /dev/null +++ b/internal/provider/validator/unique_team_validator.go @@ -0,0 +1,46 @@ +package validator + +import ( + "context" + "fmt" + + model "terraform-provider-trocco/internal/provider/model/resource_group" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +type UniqueTeamValidator struct{} + +func (v UniqueTeamValidator) Description(ctx context.Context) string { + return "Ensures that team IDs are unique within the set." +} + +func (v UniqueTeamValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +func (v UniqueTeamValidator) ValidateSet(ctx context.Context, request validator.SetRequest, response *validator.SetResponse) { + var teamIDs []model.TeamRoleResourceModel + diags := request.ConfigValue.ElementsAs(ctx, &teamIDs, false) + if diags.HasError() { + response.Diagnostics.Append(diags...) + return + } + + for i, teamI := range teamIDs { + for j, teamJ := range teamIDs { + if i == j { + continue + } + + if teamI.TeamID == teamJ.TeamID { + response.Diagnostics.AddAttributeError( + request.Path, + "Duplicate Team ID", + fmt.Sprintf("Team ID %q is duplicated in the list.", teamI.TeamID), + ) + return + } + } + } +}