Skip to content

Commit dab2c45

Browse files
committed
feat: add coderd_group resource
1 parent 375a205 commit dab2c45

File tree

2 files changed

+326
-0
lines changed

2 files changed

+326
-0
lines changed

internal/provider/group_resource.go

Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
// Copyright (c) HashiCorp, Inc.
2+
// SPDX-License-Identifier: MPL-2.0
3+
4+
package provider
5+
6+
import (
7+
"context"
8+
"fmt"
9+
10+
"github.com/coder/coder/v2/codersdk"
11+
"github.com/google/uuid"
12+
"github.com/hashicorp/terraform-plugin-framework/attr"
13+
"github.com/hashicorp/terraform-plugin-framework/path"
14+
"github.com/hashicorp/terraform-plugin-framework/resource"
15+
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
16+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
17+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/setdefault"
18+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault"
19+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
20+
"github.com/hashicorp/terraform-plugin-framework/types"
21+
"github.com/hashicorp/terraform-plugin-log/tflog"
22+
)
23+
24+
// Ensure provider defined types fully satisfy framework interfaces.
25+
var _ resource.Resource = &GroupResource{}
26+
var _ resource.ResourceWithImportState = &GroupResource{}
27+
28+
func NewGroupResource() resource.Resource {
29+
return &GroupResource{}
30+
}
31+
32+
// GroupResource defines the resource implementation.
33+
type GroupResource struct {
34+
data *CoderdProviderData
35+
}
36+
37+
// GroupResourceModel describes the resource data model.
38+
type GroupResourceModel struct {
39+
ID types.String `tfsdk:"id"`
40+
41+
Name types.String `tfsdk:"name"`
42+
DisplayName types.String `tfsdk:"display_name"`
43+
AvatarURL types.String `tfsdk:"avatar_url"`
44+
QuotaAllowance types.Int32 `tfsdk:"quota_allowance"`
45+
OrganizationID types.String `tfsdk:"organization_id"`
46+
Members types.Set `tfsdk:"members"`
47+
}
48+
49+
func (r *GroupResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
50+
resp.TypeName = req.ProviderTypeName + "_group"
51+
}
52+
53+
func (r *GroupResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
54+
resp.Schema = schema.Schema{
55+
MarkdownDescription: "A group on the Coder deployment.",
56+
57+
Attributes: map[string]schema.Attribute{
58+
"id": schema.StringAttribute{
59+
MarkdownDescription: "Group ID.",
60+
Computed: true,
61+
},
62+
"name": schema.StringAttribute{
63+
MarkdownDescription: "The unique name of the group.",
64+
Required: true,
65+
},
66+
"display_name": schema.StringAttribute{
67+
MarkdownDescription: "The display name of the group. Defaults to the group name.",
68+
Computed: true,
69+
Optional: true,
70+
// Defaulted in Create
71+
},
72+
"avatar_url": schema.StringAttribute{
73+
MarkdownDescription: "The URL of the group's avatar.",
74+
Computed: true,
75+
Optional: true,
76+
Default: stringdefault.StaticString(""),
77+
},
78+
// Int32 in the db
79+
"quota_allowance": schema.Int32Attribute{
80+
MarkdownDescription: "The number of quota credits to allocate to each user in the group.",
81+
Required: true,
82+
},
83+
"organization_id": schema.StringAttribute{
84+
MarkdownDescription: "The organization ID that the group belongs to.",
85+
Required: true,
86+
PlanModifiers: []planmodifier.String{
87+
stringplanmodifier.RequiresReplace(),
88+
},
89+
},
90+
"members": schema.SetAttribute{
91+
MarkdownDescription: "Members of the group, by ID.",
92+
ElementType: types.StringType,
93+
Computed: true,
94+
Optional: true,
95+
Default: setdefault.StaticValue(types.SetValueMust(types.StringType, []attr.Value{})),
96+
},
97+
},
98+
}
99+
}
100+
101+
func (r *GroupResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
102+
// Prevent panic if the provider has not been configured.
103+
if req.ProviderData == nil {
104+
return
105+
}
106+
107+
data, ok := req.ProviderData.(*CoderdProviderData)
108+
109+
if !ok {
110+
resp.Diagnostics.AddError(
111+
"Unexpected Resource Configure Type",
112+
fmt.Sprintf("Expected *CoderdProviderData, got: %T. Please report this issue to the provider developers.", req.ProviderData),
113+
)
114+
115+
return
116+
}
117+
118+
r.data = data
119+
}
120+
121+
func (r *GroupResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
122+
var data GroupResourceModel
123+
124+
// Read Terraform plan data into the model
125+
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
126+
127+
if resp.Diagnostics.HasError() {
128+
return
129+
}
130+
131+
client := r.data.Client
132+
133+
orgID, err := uuid.Parse(data.OrganizationID.ValueString())
134+
if err != nil {
135+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to parse supplied organization ID as UUID, got error: %s", err))
136+
}
137+
138+
displayName := data.Name.ValueString()
139+
if data.DisplayName.ValueString() != "" {
140+
displayName = data.DisplayName.ValueString()
141+
}
142+
143+
tflog.Trace(ctx, "creating group")
144+
group, err := client.CreateGroup(ctx, orgID, codersdk.CreateGroupRequest{
145+
Name: data.Name.ValueString(),
146+
DisplayName: displayName,
147+
AvatarURL: data.AvatarURL.ValueString(),
148+
QuotaAllowance: 0,
149+
})
150+
if err != nil {
151+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create group, got error: %s", err))
152+
return
153+
}
154+
tflog.Trace(ctx, "successfully created group", map[string]any{
155+
"id": group.ID.String(),
156+
})
157+
data.ID = types.StringValue(group.ID.String())
158+
data.DisplayName = types.StringValue(group.DisplayName)
159+
160+
tflog.Trace(ctx, "setting group members")
161+
var members []string
162+
resp.Diagnostics.Append(
163+
data.Members.ElementsAs(ctx, &members, false)...,
164+
)
165+
group, err = client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{
166+
AddUsers: members,
167+
})
168+
if err != nil {
169+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to add members to group, got error: %s", err))
170+
return
171+
}
172+
tflog.Trace(ctx, "successfully set group members")
173+
174+
// Save data into Terraform state
175+
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
176+
}
177+
178+
func (r *GroupResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
179+
var data GroupResourceModel
180+
181+
// Read Terraform prior state data into the model
182+
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
183+
184+
if resp.Diagnostics.HasError() {
185+
return
186+
}
187+
188+
client := r.data.Client
189+
190+
groupID, err := uuid.Parse(data.ID.ValueString())
191+
if err != nil {
192+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to parse supplied group ID as UUID, got error: %s", err))
193+
}
194+
195+
group, err := client.Group(ctx, groupID)
196+
if err != nil {
197+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get group, got error: %s", err))
198+
return
199+
}
200+
201+
data.Name = types.StringValue(group.Name)
202+
data.DisplayName = types.StringValue(group.DisplayName)
203+
data.AvatarURL = types.StringValue(group.AvatarURL)
204+
data.QuotaAllowance = types.Int32Value(int32(group.QuotaAllowance))
205+
data.OrganizationID = types.StringValue(group.OrganizationID.String())
206+
members := make([]attr.Value, 0, len(group.Members))
207+
for _, member := range group.Members {
208+
members = append(members, types.StringValue(member.ID.String()))
209+
}
210+
data.Members = types.SetValueMust(types.StringType, members)
211+
212+
// Save updated data into Terraform state
213+
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
214+
}
215+
216+
func (r *GroupResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
217+
var data GroupResourceModel
218+
219+
// Read Terraform plan data into the model
220+
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
221+
222+
if resp.Diagnostics.HasError() {
223+
return
224+
}
225+
226+
client := r.data.Client
227+
groupID, err := uuid.Parse(data.ID.ValueString())
228+
if err != nil {
229+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to parse supplied group ID as UUID, got error: %s", err))
230+
}
231+
232+
group, err := client.Group(ctx, groupID)
233+
if err != nil {
234+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get group, got error: %s", err))
235+
return
236+
}
237+
var newMembers []string
238+
resp.Diagnostics.Append(
239+
data.Members.ElementsAs(ctx, &newMembers, false)...,
240+
)
241+
add, remove := memberDiff(group.Members, newMembers)
242+
tflog.Trace(ctx, "updating group", map[string]any{
243+
"new_members": add,
244+
"removed_members": remove,
245+
"new_name": data.Name,
246+
"new_displayname": data.DisplayName,
247+
"new_avatarurl": data.AvatarURL,
248+
"new_quota": data.QuotaAllowance,
249+
})
250+
251+
quotaAllowance := int(data.QuotaAllowance.ValueInt32())
252+
client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{
253+
AddUsers: add,
254+
RemoveUsers: remove,
255+
Name: data.Name.ValueString(),
256+
DisplayName: data.DisplayName.ValueStringPointer(),
257+
AvatarURL: data.AvatarURL.ValueStringPointer(),
258+
QuotaAllowance: &quotaAllowance,
259+
})
260+
tflog.Trace(ctx, "successfully updated group")
261+
262+
// Save updated data into Terraform state
263+
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
264+
}
265+
266+
func (r *GroupResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
267+
var data GroupResourceModel
268+
269+
// Read Terraform prior state data into the model
270+
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
271+
272+
if resp.Diagnostics.HasError() {
273+
return
274+
}
275+
276+
client := r.data.Client
277+
groupID, err := uuid.Parse(data.ID.ValueString())
278+
if err != nil {
279+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to parse supplied group ID as UUID, got error: %s", err))
280+
}
281+
282+
tflog.Trace(ctx, "deleting group")
283+
err = client.DeleteGroup(ctx, groupID)
284+
if err != nil {
285+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete group, got error: %s", err))
286+
return
287+
}
288+
tflog.Trace(ctx, "successfully deleted group")
289+
}
290+
291+
func (r *GroupResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
292+
resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
293+
}
294+
295+
func memberDiff(curMembers []codersdk.ReducedUser, newMembers []string) (add, remove []string) {
296+
curSet := make(map[string]struct{}, len(curMembers))
297+
newSet := make(map[string]struct{}, len(newMembers))
298+
299+
for _, user := range curMembers {
300+
curSet[user.ID.String()] = struct{}{}
301+
}
302+
for _, userID := range newMembers {
303+
newSet[userID] = struct{}{}
304+
if _, exists := curSet[userID]; !exists {
305+
add = append(add, userID)
306+
}
307+
}
308+
for _, user := range curMembers {
309+
if _, exists := newSet[user.ID.String()]; !exists {
310+
remove = append(remove, user.ID.String())
311+
}
312+
}
313+
return add, remove
314+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// Copyright (c) HashiCorp, Inc.
2+
// SPDX-License-Identifier: MPL-2.0
3+
4+
package provider
5+
6+
import (
7+
"testing"
8+
)
9+
10+
func TestAccGroupResource(t *testing.T) {
11+
12+
}

0 commit comments

Comments
 (0)