Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 62ed2be

Browse files
committedJul 19, 2024·
feat: add coderd_group data source
1 parent b3458c7 commit 62ed2be

File tree

4 files changed

+470
-0
lines changed

4 files changed

+470
-0
lines changed
 

‎docs/data-sources/group.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
# generated by https://github.com/hashicorp/terraform-plugin-docs
3+
page_title: "coderd_group Data Source - coderd"
4+
subcategory: ""
5+
description: |-
6+
An existing group on the coder deployment.
7+
---
8+
9+
# coderd_group (Data Source)
10+
11+
An existing group on the coder deployment.
12+
13+
14+
15+
<!-- schema generated by tfplugindocs -->
16+
## Schema
17+
18+
### Optional
19+
20+
- `id` (String) The ID of the group to retrieve. This field will be populated if a name and organization ID is supplied.
21+
- `name` (String) The name of the group to retrieve. This field will be populated if an ID is supplied.
22+
- `organization_id` (String) The organization ID that the group belongs to. This field will be populated if an ID is supplied.
23+
24+
### Read-Only
25+
26+
- `avatar_url` (String)
27+
- `display_name` (String)
28+
- `members` (Attributes Set) Members of the group. (see [below for nested schema](#nestedatt--members))
29+
- `quota_allowance` (Number) The number of quota credits to allocate to each user in the group.
30+
- `source` (String) The source of the group. Either 'oidc' or 'user'.
31+
32+
<a id="nestedatt--members"></a>
33+
### Nested Schema for `members`
34+
35+
Read-Only:
36+
37+
- `created_at` (Number) Unix timestamp of when the member was created.
38+
- `email` (String)
39+
- `id` (String)
40+
- `last_seen_at` (Number) Unix timestamp of when the member was last seen.
41+
- `login_type` (String) The login type of the member. Can be 'oidc', 'token', 'password', 'github' or 'none'.
42+
- `status` (String) The status of the member. Can be 'active', 'dormant' or 'suspended'.
43+
- `theme_preference` (String)
44+
- `username` (String)
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
package provider
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/coder/coder/v2/codersdk"
8+
"github.com/google/uuid"
9+
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
10+
"github.com/hashicorp/terraform-plugin-framework/datasource"
11+
"github.com/hashicorp/terraform-plugin-framework/datasource/schema"
12+
"github.com/hashicorp/terraform-plugin-framework/path"
13+
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
14+
"github.com/hashicorp/terraform-plugin-framework/types"
15+
)
16+
17+
// Ensure provider defined types fully satisfy framework interfaces.
18+
var _ datasource.DataSource = &GroupDataSource{}
19+
20+
func NewGroupDataSource() datasource.DataSource {
21+
return &GroupDataSource{}
22+
}
23+
24+
// GroupDataSource defines the data source implementation.
25+
type GroupDataSource struct {
26+
data *CoderdProviderData
27+
}
28+
29+
// GroupDataSourceModel describes the data source data model.
30+
type GroupDataSourceModel struct {
31+
// ID or name and organization ID must be set
32+
ID types.String `tfsdk:"id"`
33+
Name types.String `tfsdk:"name"`
34+
OrganizationID types.String `tfsdk:"organization_id"`
35+
36+
DisplayName types.String `tfsdk:"display_name"`
37+
AvatarURL types.String `tfsdk:"avatar_url"`
38+
QuotaAllowance types.Int32 `tfsdk:"quota_allowance"`
39+
Source types.String `tfsdk:"source"`
40+
Members []Member `tfsdk:"members"`
41+
}
42+
43+
type Member struct {
44+
ID types.String `tfsdk:"id"`
45+
Username types.String `tfsdk:"username"`
46+
Email types.String `tfsdk:"email"`
47+
CreatedAt types.Int64 `tfsdk:"created_at"`
48+
LastSeenAt types.Int64 `tfsdk:"last_seen_at"`
49+
Status types.String `tfsdk:"status"`
50+
LoginType types.String `tfsdk:"login_type"`
51+
ThemePreference types.String `tfsdk:"theme_preference"`
52+
}
53+
54+
func (d *GroupDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
55+
resp.TypeName = req.ProviderTypeName + "_group"
56+
}
57+
58+
func (d *GroupDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) {
59+
resp.Schema = schema.Schema{
60+
MarkdownDescription: "An existing group on the coder deployment.",
61+
62+
Attributes: map[string]schema.Attribute{
63+
"id": schema.StringAttribute{
64+
MarkdownDescription: "The ID of the group to retrieve. This field will be populated if a name and organization ID is supplied.",
65+
Optional: true,
66+
Computed: true,
67+
Validators: []validator.String{
68+
stringvalidator.AtLeastOneOf(path.Expressions{
69+
path.MatchRoot("name"),
70+
}...),
71+
},
72+
},
73+
"name": schema.StringAttribute{
74+
MarkdownDescription: "The name of the group to retrieve. This field will be populated if an ID is supplied.",
75+
Optional: true,
76+
Computed: true,
77+
Validators: []validator.String{},
78+
},
79+
"organization_id": schema.StringAttribute{
80+
MarkdownDescription: "The organization ID that the group belongs to. This field will be populated if an ID is supplied. Defaults to the provider default organization ID.",
81+
Optional: true,
82+
Computed: true,
83+
},
84+
"display_name": schema.StringAttribute{
85+
Computed: true,
86+
},
87+
"avatar_url": schema.StringAttribute{
88+
Computed: true,
89+
},
90+
"quota_allowance": schema.Int32Attribute{
91+
MarkdownDescription: "The number of quota credits to allocate to each user in the group.",
92+
Computed: true,
93+
},
94+
"source": schema.StringAttribute{
95+
MarkdownDescription: "The source of the group. Either 'oidc' or 'user'.",
96+
Computed: true,
97+
},
98+
"members": schema.SetNestedAttribute{
99+
MarkdownDescription: "Members of the group.",
100+
Computed: true,
101+
NestedObject: schema.NestedAttributeObject{
102+
Attributes: map[string]schema.Attribute{
103+
"id": schema.StringAttribute{
104+
Computed: true,
105+
},
106+
"username": schema.StringAttribute{
107+
Computed: true,
108+
},
109+
"email": schema.StringAttribute{
110+
Computed: true,
111+
},
112+
"created_at": schema.Int64Attribute{
113+
MarkdownDescription: "Unix timestamp of when the member was created.",
114+
Computed: true,
115+
},
116+
"last_seen_at": schema.Int64Attribute{
117+
MarkdownDescription: "Unix timestamp of when the member was last seen.",
118+
Computed: true,
119+
},
120+
"status": schema.StringAttribute{
121+
MarkdownDescription: "The status of the member. Can be 'active', 'dormant' or 'suspended'.",
122+
Computed: true,
123+
},
124+
"login_type": schema.StringAttribute{
125+
MarkdownDescription: "The login type of the member. Can be 'oidc', 'token', 'password', 'github' or 'none'.",
126+
Computed: true,
127+
},
128+
"theme_preference": schema.StringAttribute{
129+
Computed: true,
130+
},
131+
// TODO: Upgrade requested user type if required
132+
},
133+
},
134+
},
135+
},
136+
}
137+
}
138+
139+
func (d *GroupDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
140+
// Prevent panic if the provider has not been configured.
141+
if req.ProviderData == nil {
142+
return
143+
}
144+
145+
data, ok := req.ProviderData.(*CoderdProviderData)
146+
147+
if !ok {
148+
resp.Diagnostics.AddError(
149+
"Unexpected Data Source Configure Type",
150+
fmt.Sprintf("Expected *CoderdProviderData, got: %T. Please report this issue to the provider developers.", req.ProviderData),
151+
)
152+
153+
return
154+
}
155+
156+
d.data = data
157+
}
158+
159+
func (d *GroupDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) {
160+
var data GroupDataSourceModel
161+
162+
// Read Terraform configuration data into the model
163+
resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
164+
165+
if resp.Diagnostics.HasError() {
166+
return
167+
}
168+
169+
client := d.data.Client
170+
171+
if data.OrganizationID.IsNull() {
172+
data.OrganizationID = types.StringValue(d.data.DefaultOrganizationID)
173+
}
174+
175+
var group codersdk.Group
176+
if !data.ID.IsNull() {
177+
groupID, err := uuid.Parse(data.ID.ValueString())
178+
if err != nil {
179+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to parse supplied group ID as UUID, got error: %s", err))
180+
return
181+
}
182+
183+
group, err = client.Group(ctx, groupID)
184+
if err != nil {
185+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get group by ID, got error: %s", err))
186+
return
187+
}
188+
data.Name = types.StringValue(group.Name)
189+
data.OrganizationID = types.StringValue(group.OrganizationID.String())
190+
} else {
191+
orgID, err := uuid.Parse(data.OrganizationID.ValueString())
192+
if err != nil {
193+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to parse supplied organization ID as UUID, got error: %s", err))
194+
return
195+
}
196+
group, err = client.GroupByOrgAndName(ctx, orgID, data.Name.ValueString())
197+
if err != nil {
198+
resp.Diagnostics.AddError("Failed to get group by name and org ID", err.Error())
199+
return
200+
}
201+
data.ID = types.StringValue(group.ID.String())
202+
}
203+
204+
data.DisplayName = types.StringValue(group.DisplayName)
205+
data.AvatarURL = types.StringValue(group.AvatarURL)
206+
data.QuotaAllowance = types.Int32Value(int32(group.QuotaAllowance))
207+
members := make([]Member, 0, len(group.Members))
208+
for _, member := range group.Members {
209+
members = append(members, Member{
210+
ID: types.StringValue(member.ID.String()),
211+
Username: types.StringValue(member.Username),
212+
Email: types.StringValue(member.Email),
213+
CreatedAt: types.Int64Value(member.CreatedAt.Unix()),
214+
LastSeenAt: types.Int64Value(member.LastSeenAt.Unix()),
215+
Status: types.StringValue(string(member.Status)),
216+
LoginType: types.StringValue(string(member.LoginType)),
217+
ThemePreference: types.StringValue(member.ThemePreference),
218+
})
219+
}
220+
data.Members = members
221+
data.Source = types.StringValue(string(group.Source))
222+
223+
// Save data into Terraform state
224+
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
225+
}
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
package provider
2+
3+
import (
4+
"context"
5+
"os"
6+
"regexp"
7+
"strings"
8+
"testing"
9+
"text/template"
10+
11+
"github.com/coder/coder/v2/codersdk"
12+
"github.com/coder/terraform-provider-coderd/integration"
13+
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
14+
"github.com/stretchr/testify/require"
15+
)
16+
17+
func TestAccGroupDataSource(t *testing.T) {
18+
if os.Getenv("TF_ACC") == "" {
19+
t.Skip("Acceptance tests are disabled.")
20+
}
21+
ctx := context.Background()
22+
client := integration.StartCoder(ctx, t, "group_data_acc", true)
23+
firstUser, err := client.User(ctx, codersdk.Me)
24+
require.NoError(t, err)
25+
26+
user1, err := client.CreateUser(ctx, codersdk.CreateUserRequest{
27+
Email: "example@coder.com",
28+
Username: "example",
29+
Password: "SomeSecurePassword!",
30+
UserLoginType: "password",
31+
OrganizationID: firstUser.OrganizationIDs[0],
32+
})
33+
require.NoError(t, err)
34+
35+
user2, err := client.CreateUser(ctx, codersdk.CreateUserRequest{
36+
Email: "example2@coder.com",
37+
Username: "example2",
38+
Password: "SomeSecurePassword!",
39+
UserLoginType: "password",
40+
OrganizationID: firstUser.OrganizationIDs[0],
41+
})
42+
require.NoError(t, err)
43+
44+
group, err := client.CreateGroup(ctx, firstUser.OrganizationIDs[0], codersdk.CreateGroupRequest{
45+
Name: "example-group",
46+
DisplayName: "Example Group",
47+
AvatarURL: "https://google.com",
48+
QuotaAllowance: 10,
49+
})
50+
require.NoError(t, err)
51+
group, err = client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{
52+
AddUsers: []string{user1.ID.String(), user2.ID.String()},
53+
})
54+
require.NoError(t, err)
55+
56+
checkFn := resource.ComposeAggregateTestCheckFunc(
57+
resource.TestCheckResourceAttr("data.coderd_group.test", "id", group.ID.String()),
58+
resource.TestCheckResourceAttr("data.coderd_group.test", "name", "example-group"),
59+
resource.TestCheckResourceAttr("data.coderd_group.test", "organization_id", firstUser.OrganizationIDs[0].String()),
60+
resource.TestCheckResourceAttr("data.coderd_group.test", "display_name", "Example Group"),
61+
resource.TestCheckResourceAttr("data.coderd_group.test", "avatar_url", "https://google.com"),
62+
resource.TestCheckResourceAttr("data.coderd_group.test", "quota_allowance", "10"),
63+
resource.TestCheckResourceAttr("data.coderd_group.test", "members.#", "2"),
64+
resource.TestCheckTypeSetElemNestedAttrs("data.coderd_group.test", "members.*", map[string]string{
65+
"id": user1.ID.String(),
66+
}),
67+
resource.TestCheckTypeSetElemNestedAttrs("data.coderd_group.test", "members.*", map[string]string{
68+
"id": user2.ID.String(),
69+
}),
70+
resource.TestCheckResourceAttr("data.coderd_group.test", "source", "user"),
71+
)
72+
73+
t.Run("GroupByIDOk", func(t *testing.T) {
74+
cfg := testAccGroupDataSourceConfig{
75+
URL: client.URL.String(),
76+
Token: client.SessionToken(),
77+
ID: PtrTo(group.ID.String()),
78+
}
79+
resource.Test(t, resource.TestCase{
80+
PreCheck: func() { testAccPreCheck(t) },
81+
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
82+
Steps: []resource.TestStep{
83+
{
84+
Config: cfg.String(t),
85+
Check: checkFn,
86+
},
87+
},
88+
})
89+
})
90+
91+
t.Run("GroupByNameAndOrganizationIDOk", func(t *testing.T) {
92+
cfg := testAccGroupDataSourceConfig{
93+
URL: client.URL.String(),
94+
Token: client.SessionToken(),
95+
OrganizationID: PtrTo(firstUser.OrganizationIDs[0].String()),
96+
Name: PtrTo("example-group"),
97+
}
98+
resource.Test(t, resource.TestCase{
99+
PreCheck: func() { testAccPreCheck(t) },
100+
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
101+
Steps: []resource.TestStep{
102+
{
103+
Config: cfg.String(t),
104+
Check: checkFn,
105+
},
106+
},
107+
})
108+
})
109+
110+
t.Run("UseDefaultOrganizationIDOk", func(t *testing.T) {
111+
cfg := testAccGroupDataSourceConfig{
112+
URL: client.URL.String(),
113+
Token: client.SessionToken(),
114+
Name: PtrTo("example-group"),
115+
}
116+
resource.Test(t, resource.TestCase{
117+
PreCheck: func() { testAccPreCheck(t) },
118+
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
119+
Steps: []resource.TestStep{
120+
{
121+
Config: cfg.String(t),
122+
Check: checkFn,
123+
},
124+
},
125+
})
126+
})
127+
128+
t.Run("OrgIDOnlyError", func(t *testing.T) {
129+
cfg := testAccGroupDataSourceConfig{
130+
URL: client.URL.String(),
131+
Token: client.SessionToken(),
132+
OrganizationID: PtrTo(firstUser.OrganizationIDs[0].String()),
133+
}
134+
resource.Test(t, resource.TestCase{
135+
PreCheck: func() { testAccPreCheck(t) },
136+
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
137+
// Neither ID nor Username
138+
Steps: []resource.TestStep{
139+
{
140+
Config: cfg.String(t),
141+
ExpectError: regexp.MustCompile(`At least one attribute out of \[name,id\] must be specified`),
142+
},
143+
},
144+
})
145+
})
146+
147+
t.Run("NoneError", func(t *testing.T) {
148+
cfg := testAccGroupDataSourceConfig{
149+
URL: client.URL.String(),
150+
Token: client.SessionToken(),
151+
}
152+
resource.Test(t, resource.TestCase{
153+
PreCheck: func() { testAccPreCheck(t) },
154+
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
155+
// Neither ID nor Username
156+
Steps: []resource.TestStep{
157+
{
158+
Config: cfg.String(t),
159+
ExpectError: regexp.MustCompile(`At least one attribute out of \[name,id\] must be specified`),
160+
},
161+
},
162+
})
163+
})
164+
}
165+
166+
type testAccGroupDataSourceConfig struct {
167+
URL string
168+
Token string
169+
170+
ID *string
171+
Name *string
172+
OrganizationID *string
173+
}
174+
175+
func (c testAccGroupDataSourceConfig) String(t *testing.T) string {
176+
tpl := `
177+
provider coderd {
178+
url = "{{.URL}}"
179+
token = "{{.Token}}"
180+
}
181+
182+
data "coderd_group" "test" {
183+
id = {{orNull .ID}}
184+
name = {{orNull .Name}}
185+
organization_id = {{orNull .OrganizationID}}
186+
}
187+
`
188+
189+
funcMap := template.FuncMap{
190+
"orNull": PrintOrNull,
191+
}
192+
193+
buf := strings.Builder{}
194+
tmpl, err := template.New("groupDataSource").Funcs(funcMap).Parse(tpl)
195+
require.NoError(t, err)
196+
197+
err = tmpl.Execute(&buf, c)
198+
require.NoError(t, err)
199+
return buf.String()
200+
}

‎internal/provider/provider.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ func (p *CoderdProvider) Resources(ctx context.Context) []func() resource.Resour
128128

129129
func (p *CoderdProvider) DataSources(ctx context.Context) []func() datasource.DataSource {
130130
return []func() datasource.DataSource{
131+
NewGroupDataSource,
131132
NewUserDataSource,
132133
}
133134
}

0 commit comments

Comments
 (0)
Please sign in to comment.