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 355058d

Browse files
committedJul 18, 2024·
feat: add coderd_group data source
1 parent f555328 commit 355058d

File tree

4 files changed

+471
-0
lines changed

4 files changed

+471
-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+
stringvalidator.AlsoRequires(path.Expressions{
79+
path.MatchRoot("organization_id"),
80+
}...),
81+
},
82+
},
83+
"organization_id": schema.StringAttribute{
84+
MarkdownDescription: "The organization ID that the group belongs to. This field will be populated if an ID is supplied.",
85+
Optional: true,
86+
Computed: true,
87+
},
88+
"display_name": schema.StringAttribute{
89+
Computed: true,
90+
},
91+
"avatar_url": schema.StringAttribute{
92+
Computed: true,
93+
},
94+
"quota_allowance": schema.Int32Attribute{
95+
MarkdownDescription: "The number of quota credits to allocate to each user in the group.",
96+
Computed: true,
97+
},
98+
"source": schema.StringAttribute{
99+
MarkdownDescription: "The source of the group. Either 'oidc' or 'user'.",
100+
Computed: true,
101+
},
102+
"members": schema.SetNestedAttribute{
103+
MarkdownDescription: "Members of the group.",
104+
Computed: true,
105+
NestedObject: schema.NestedAttributeObject{
106+
Attributes: map[string]schema.Attribute{
107+
"id": schema.StringAttribute{
108+
Computed: true,
109+
},
110+
"username": schema.StringAttribute{
111+
Computed: true,
112+
},
113+
"email": schema.StringAttribute{
114+
Computed: true,
115+
},
116+
"created_at": schema.Int64Attribute{
117+
MarkdownDescription: "Unix timestamp of when the member was created.",
118+
Computed: true,
119+
},
120+
"last_seen_at": schema.Int64Attribute{
121+
MarkdownDescription: "Unix timestamp of when the member was last seen.",
122+
Computed: true,
123+
},
124+
"status": schema.StringAttribute{
125+
MarkdownDescription: "The status of the member. Can be 'active', 'dormant' or 'suspended'.",
126+
Computed: true,
127+
},
128+
"login_type": schema.StringAttribute{
129+
MarkdownDescription: "The login type of the member. Can be 'oidc', 'token', 'password', 'github' or 'none'.",
130+
Computed: true,
131+
},
132+
"theme_preference": schema.StringAttribute{
133+
Computed: true,
134+
},
135+
// TODO: Upgrade requested user type if required
136+
},
137+
},
138+
},
139+
},
140+
}
141+
}
142+
143+
func (d *GroupDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
144+
// Prevent panic if the provider has not been configured.
145+
if req.ProviderData == nil {
146+
return
147+
}
148+
149+
data, ok := req.ProviderData.(*CoderdProviderData)
150+
151+
if !ok {
152+
resp.Diagnostics.AddError(
153+
"Unexpected Data Source Configure Type",
154+
fmt.Sprintf("Expected *CoderdProviderData, got: %T. Please report this issue to the provider developers.", req.ProviderData),
155+
)
156+
157+
return
158+
}
159+
160+
d.data = data
161+
}
162+
163+
func (d *GroupDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) {
164+
var data GroupDataSourceModel
165+
166+
// Read Terraform configuration data into the model
167+
resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
168+
169+
if resp.Diagnostics.HasError() {
170+
return
171+
}
172+
173+
client := d.data.Client
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: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
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("GroupNameOnlyError", 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+
// Neither ID nor Username
120+
Steps: []resource.TestStep{
121+
{
122+
Config: cfg.String(t),
123+
ExpectError: regexp.MustCompile(`Attribute "organization_id" must be specified when "name" is specified`),
124+
},
125+
},
126+
})
127+
})
128+
129+
t.Run("OrgIDOnlyError", func(t *testing.T) {
130+
cfg := testAccGroupDataSourceConfig{
131+
URL: client.URL.String(),
132+
Token: client.SessionToken(),
133+
OrganizationID: PtrTo(firstUser.OrganizationIDs[0].String()),
134+
}
135+
resource.Test(t, resource.TestCase{
136+
PreCheck: func() { testAccPreCheck(t) },
137+
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
138+
// Neither ID nor Username
139+
Steps: []resource.TestStep{
140+
{
141+
Config: cfg.String(t),
142+
ExpectError: regexp.MustCompile(`At least one attribute out of \[name,id\] must be specified`),
143+
},
144+
},
145+
})
146+
})
147+
148+
t.Run("NoneError", func(t *testing.T) {
149+
cfg := testAccGroupDataSourceConfig{
150+
URL: client.URL.String(),
151+
Token: client.SessionToken(),
152+
}
153+
resource.Test(t, resource.TestCase{
154+
PreCheck: func() { testAccPreCheck(t) },
155+
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
156+
// Neither ID nor Username
157+
Steps: []resource.TestStep{
158+
{
159+
Config: cfg.String(t),
160+
ExpectError: regexp.MustCompile(`At least one attribute out of \[name,id\] must be specified`),
161+
},
162+
},
163+
})
164+
})
165+
}
166+
167+
type testAccGroupDataSourceConfig struct {
168+
URL string
169+
Token string
170+
171+
ID *string
172+
Name *string
173+
OrganizationID *string
174+
}
175+
176+
func (c testAccGroupDataSourceConfig) String(t *testing.T) string {
177+
tpl := `
178+
provider coderd {
179+
url = "{{.URL}}"
180+
token = "{{.Token}}"
181+
}
182+
183+
data "coderd_group" "test" {
184+
id = {{orNull .ID}}
185+
name = {{orNull .Name}}
186+
organization_id = {{orNull .OrganizationID}}
187+
}
188+
`
189+
190+
funcMap := template.FuncMap{
191+
"orNull": PrintOrNull,
192+
}
193+
194+
buf := strings.Builder{}
195+
tmpl, err := template.New("groupDataSource").Funcs(funcMap).Parse(tpl)
196+
require.NoError(t, err)
197+
198+
err = tmpl.Execute(&buf, c)
199+
require.NoError(t, err)
200+
return buf.String()
201+
}

‎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.