Skip to content

Commit dec61bf

Browse files
committed
feat: add coderd_organization resource
1 parent 557da95 commit dec61bf

File tree

4 files changed

+517
-0
lines changed

4 files changed

+517
-0
lines changed

Diff for: docs/resources/organization.md

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
# generated by https://github.com/hashicorp/terraform-plugin-docs
3+
page_title: "coderd_organization Resource - coderd"
4+
subcategory: ""
5+
description: |-
6+
An organization on the coder deployment.
7+
---
8+
9+
# coderd_organization (Resource)
10+
11+
An organization on the coder deployment.
12+
13+
14+
15+
<!-- schema generated by tfplugindocs -->
16+
## Schema
17+
18+
### Required
19+
20+
- `name` (String)
21+
22+
### Optional
23+
24+
- `description` (String)
25+
- `display_name` (String)
26+
- `icon` (String)
27+
- `members` (Set of String) Members of the organization, by ID. If null, members will not be added or removed by Terraform.
28+
29+
### Read-Only
30+
31+
- `id` (String) The ID of this resource.

Diff for: internal/provider/organization_resource.go

+322
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
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/coderd/util/slice"
11+
"github.com/coder/coder/v2/codersdk"
12+
"github.com/google/uuid"
13+
"github.com/hashicorp/terraform-plugin-framework/attr"
14+
"github.com/hashicorp/terraform-plugin-framework/path"
15+
"github.com/hashicorp/terraform-plugin-framework/resource"
16+
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
17+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
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 = &OrganizationResource{}
26+
var _ resource.ResourceWithImportState = &OrganizationResource{}
27+
28+
func NewOrganizationResource() resource.Resource {
29+
return &OrganizationResource{}
30+
}
31+
32+
// OrganizationResource defines the resource implementation.
33+
type OrganizationResource struct {
34+
data *CoderdProviderData
35+
}
36+
37+
// OrganizationResourceModel describes the resource data model.
38+
type OrganizationResourceModel struct {
39+
ID UUID `tfsdk:"id"`
40+
41+
Name types.String `tfsdk:"name"`
42+
DisplayName types.String `tfsdk:"display_name"`
43+
Description types.String `tfsdk:"description"`
44+
Icon types.String `tfsdk:"icon"`
45+
Members types.Set `tfsdk:"members"`
46+
}
47+
48+
func (r *OrganizationResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
49+
resp.TypeName = req.ProviderTypeName + "_organization"
50+
}
51+
52+
func (r *OrganizationResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
53+
resp.Schema = schema.Schema{
54+
MarkdownDescription: "An organization on the coder deployment.",
55+
56+
Attributes: map[string]schema.Attribute{
57+
"id": schema.StringAttribute{
58+
CustomType: UUIDType,
59+
Computed: true,
60+
PlanModifiers: []planmodifier.String{
61+
stringplanmodifier.UseStateForUnknown(),
62+
},
63+
},
64+
"name": schema.StringAttribute{
65+
Required: true,
66+
},
67+
"display_name": schema.StringAttribute{
68+
Optional: true,
69+
Computed: true,
70+
},
71+
"description": schema.StringAttribute{
72+
Optional: true,
73+
Computed: true,
74+
Default: stringdefault.StaticString(""),
75+
},
76+
"icon": schema.StringAttribute{
77+
Optional: true,
78+
Computed: true,
79+
Default: stringdefault.StaticString(""),
80+
},
81+
"members": schema.SetAttribute{
82+
MarkdownDescription: "Members of the organization, by ID. If null, members will not be added or removed by Terraform.",
83+
ElementType: UUIDType,
84+
Optional: true,
85+
},
86+
// TODO: Custom roles, premium license gated
87+
},
88+
}
89+
}
90+
91+
func (r *OrganizationResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
92+
// Prevent panic if the provider has not been configured.
93+
if req.ProviderData == nil {
94+
return
95+
}
96+
97+
data, ok := req.ProviderData.(*CoderdProviderData)
98+
99+
if !ok {
100+
resp.Diagnostics.AddError(
101+
"Unexpected Resource Configure Type",
102+
fmt.Sprintf("Expected *CoderdProviderData, got: %T. Please report this issue to the provider developers.", req.ProviderData),
103+
)
104+
105+
return
106+
}
107+
108+
r.data = data
109+
}
110+
111+
func (r *OrganizationResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
112+
var data OrganizationResourceModel
113+
114+
// Read Terraform plan data into the model
115+
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
116+
117+
if resp.Diagnostics.HasError() {
118+
return
119+
}
120+
121+
client := r.data.Client
122+
123+
displayName := data.Name.ValueString()
124+
if data.DisplayName.ValueString() != "" {
125+
displayName = data.DisplayName.ValueString()
126+
}
127+
128+
tflog.Trace(ctx, "creating organization")
129+
org, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{
130+
Name: data.Name.ValueString(),
131+
DisplayName: displayName,
132+
Description: data.Description.ValueString(),
133+
Icon: data.Icon.ValueString(),
134+
})
135+
if err != nil {
136+
resp.Diagnostics.AddError("Failed to create organization", err.Error())
137+
return
138+
}
139+
tflog.Trace(ctx, "successfully created organization", map[string]any{
140+
"id": org.ID,
141+
})
142+
data.ID = UUIDValue(org.ID)
143+
data.DisplayName = types.StringValue(org.DisplayName)
144+
145+
tflog.Trace(ctx, "setting organization members")
146+
var members []UUID
147+
resp.Diagnostics.Append(data.Members.ElementsAs(ctx, &members, false)...)
148+
if resp.Diagnostics.HasError() {
149+
return
150+
}
151+
for _, memberID := range members {
152+
_, err = client.PostOrganizationMember(ctx, org.ID, memberID.ValueString())
153+
if err != nil {
154+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to add member %s to organization %s, got error: %s", memberID, org.ID, err))
155+
return
156+
}
157+
}
158+
159+
me, err := client.User(ctx, codersdk.Me)
160+
if err != nil {
161+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get current user, got error: %s", err))
162+
return
163+
}
164+
165+
// If the logged-in user isn't in the members list, remove them from the organization (as they were added by default)
166+
// Ideally, future Coder versions won't add the logged-in user by default.
167+
if !slice.Contains(members, UUIDValue(me.ID)) {
168+
err = client.DeleteOrganizationMember(ctx, org.ID, codersdk.Me)
169+
if err != nil {
170+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete self from new organization: %s", err))
171+
}
172+
}
173+
174+
tflog.Trace(ctx, "successfully set organization members")
175+
// Save data into Terraform state
176+
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
177+
}
178+
179+
func (r *OrganizationResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
180+
var data OrganizationResourceModel
181+
182+
// Read Terraform prior state data into the model
183+
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
184+
185+
if resp.Diagnostics.HasError() {
186+
return
187+
}
188+
189+
client := r.data.Client
190+
191+
orgID := data.ID.ValueUUID()
192+
org, err := client.Organization(ctx, orgID)
193+
if err != nil {
194+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get organization by ID, got error: %s", err))
195+
}
196+
197+
data.Name = types.StringValue(org.Name)
198+
data.DisplayName = types.StringValue(org.DisplayName)
199+
data.Description = types.StringValue(org.Description)
200+
data.Icon = types.StringValue(org.Icon)
201+
if !data.Members.IsNull() {
202+
members, err := client.OrganizationMembers(ctx, orgID)
203+
if err != nil {
204+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get organization members, got error: %s", err))
205+
return
206+
}
207+
memberIDs := make([]attr.Value, 0, len(members))
208+
for _, member := range members {
209+
memberIDs = append(memberIDs, UUIDValue(member.UserID))
210+
}
211+
data.Members = types.SetValueMust(UUIDType, memberIDs)
212+
}
213+
214+
// Save updated data into Terraform state
215+
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
216+
}
217+
218+
func (r *OrganizationResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
219+
var data OrganizationResourceModel
220+
221+
// Read Terraform plan data into the model
222+
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
223+
224+
if resp.Diagnostics.HasError() {
225+
return
226+
}
227+
228+
client := r.data.Client
229+
orgID := data.ID.ValueUUID()
230+
231+
orgMembers, err := client.OrganizationMembers(ctx, orgID)
232+
if err != nil {
233+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get organization members , got error: %s", err))
234+
return
235+
}
236+
237+
if !data.Members.IsNull() {
238+
var plannedMembers []UUID
239+
resp.Diagnostics.Append(data.Members.ElementsAs(ctx, &plannedMembers, false)...)
240+
if resp.Diagnostics.HasError() {
241+
return
242+
}
243+
curMembers := make([]uuid.UUID, 0, len(orgMembers))
244+
for _, member := range orgMembers {
245+
curMembers = append(curMembers, member.UserID)
246+
}
247+
add, remove := memberDiff(curMembers, plannedMembers)
248+
tflog.Trace(ctx, "updating organization members", map[string]any{
249+
"new_members": add,
250+
"removed_members": remove,
251+
})
252+
for _, memberID := range add {
253+
_, err := client.PostOrganizationMember(ctx, orgID, memberID)
254+
if err != nil {
255+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to add member %s to organization %s, got error: %s", memberID, orgID, err))
256+
return
257+
}
258+
}
259+
for _, memberID := range remove {
260+
err := client.DeleteOrganizationMember(ctx, orgID, memberID)
261+
if err != nil {
262+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to remove member %s from organization %s, got error: %s", memberID, orgID, err))
263+
return
264+
}
265+
}
266+
tflog.Trace(ctx, "successfully updated organization members")
267+
}
268+
269+
tflog.Trace(ctx, "updating organization", map[string]any{
270+
"id": orgID,
271+
"new_name": data.Name,
272+
"new_display_name": data.DisplayName,
273+
"new_description": data.Description,
274+
"new_icon": data.Icon,
275+
})
276+
_, err = client.UpdateOrganization(ctx, orgID.String(), codersdk.UpdateOrganizationRequest{
277+
Name: data.Name.ValueString(),
278+
DisplayName: data.DisplayName.ValueString(),
279+
Description: data.Description.ValueStringPointer(),
280+
Icon: data.Icon.ValueStringPointer(),
281+
})
282+
if err != nil {
283+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update organization %s, got error: %s", orgID, err))
284+
return
285+
}
286+
tflog.Trace(ctx, "successfully updated organization")
287+
288+
// Save updated data into Terraform state
289+
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
290+
}
291+
292+
func (r *OrganizationResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
293+
var data OrganizationResourceModel
294+
295+
// Read Terraform prior state data into the model
296+
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
297+
298+
if resp.Diagnostics.HasError() {
299+
return
300+
}
301+
302+
client := r.data.Client
303+
orgID := data.ID.ValueUUID()
304+
305+
tflog.Trace(ctx, "deleting organization", map[string]any{
306+
"id": orgID,
307+
})
308+
309+
err := client.DeleteOrganization(ctx, orgID.String())
310+
if err != nil {
311+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete organization %s, got error: %s", orgID, err))
312+
return
313+
}
314+
tflog.Trace(ctx, "successfully deleted organization")
315+
316+
// Read Terraform prior state data into the model
317+
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
318+
}
319+
320+
func (r *OrganizationResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
321+
resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
322+
}

0 commit comments

Comments
 (0)