Skip to content

Commit 614c77f

Browse files
committed
feat: add coderd_group data source
1 parent 31f7669 commit 614c77f

File tree

3 files changed

+375
-0
lines changed

3 files changed

+375
-0
lines changed
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
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/attr"
11+
"github.com/hashicorp/terraform-plugin-framework/datasource"
12+
"github.com/hashicorp/terraform-plugin-framework/datasource/schema"
13+
"github.com/hashicorp/terraform-plugin-framework/path"
14+
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
15+
"github.com/hashicorp/terraform-plugin-framework/types"
16+
)
17+
18+
// Ensure provider defined types fully satisfy framework interfaces.
19+
var _ datasource.DataSource = &GroupDataSource{}
20+
21+
func NewGroupDataSource() datasource.DataSource {
22+
return &GroupDataSource{}
23+
}
24+
25+
// GroupDataSource defines the data source implementation.
26+
type GroupDataSource struct {
27+
data *CoderdProviderData
28+
}
29+
30+
// GroupDataSourceModel describes the data source data model.
31+
type GroupDataSourceModel struct {
32+
// ID or name and organization ID must be set
33+
ID types.String `tfsdk:"id"`
34+
Name types.String `tfsdk:"name"`
35+
OrganizationID types.String `tfsdk:"organization_id"`
36+
37+
DisplayName types.String `tfsdk:"display_name"`
38+
AvatarURL types.String `tfsdk:"avatar_url"`
39+
QuotaAllowance types.Int32 `tfsdk:"quota_allowance"`
40+
Members types.Set `tfsdk:"members"`
41+
Source types.String `tfsdk:"source"`
42+
}
43+
44+
func (d *GroupDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
45+
resp.TypeName = req.ProviderTypeName + "_group"
46+
}
47+
48+
func (d *GroupDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) {
49+
resp.Schema = schema.Schema{
50+
MarkdownDescription: "An existing group on the coder deployment.",
51+
52+
Attributes: map[string]schema.Attribute{
53+
"id": schema.StringAttribute{
54+
MarkdownDescription: "The ID of the group to retrieve. This field will be populated if a name and organisation ID is supplied.",
55+
Optional: true,
56+
Computed: true,
57+
Validators: []validator.String{
58+
stringvalidator.AtLeastOneOf(path.Expressions{
59+
path.MatchRoot("name"),
60+
}...),
61+
},
62+
},
63+
"name": schema.StringAttribute{
64+
MarkdownDescription: "The name of the group to retrieve. This field will be populated if an ID is supplied.",
65+
Optional: true,
66+
Computed: true,
67+
Validators: []validator.String{
68+
stringvalidator.AlsoRequires(path.Expressions{
69+
path.MatchRoot("organization_id"),
70+
}...),
71+
},
72+
},
73+
"organization_id": schema.StringAttribute{
74+
MarkdownDescription: "The organization ID that the group belongs to. This field will be populated if an ID is supplied.",
75+
Optional: true,
76+
Computed: true,
77+
},
78+
"display_name": schema.StringAttribute{
79+
MarkdownDescription: "The display name of the group.",
80+
Computed: true,
81+
},
82+
"avatar_url": schema.StringAttribute{
83+
MarkdownDescription: "The URL of the group's avatar.",
84+
Computed: true,
85+
},
86+
"quota_allowance": schema.Int32Attribute{
87+
MarkdownDescription: "The number of quota credits to allocate to each user in the group.",
88+
Computed: true,
89+
},
90+
"members": schema.SetAttribute{
91+
MarkdownDescription: "Members of the group, by ID.",
92+
ElementType: types.StringType,
93+
Computed: true,
94+
},
95+
"source": schema.StringAttribute{
96+
MarkdownDescription: "The source of the group. Either 'oidc' or 'user'.",
97+
Computed: true,
98+
},
99+
},
100+
}
101+
}
102+
103+
func (d *GroupDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
104+
// Prevent panic if the provider has not been configured.
105+
if req.ProviderData == nil {
106+
return
107+
}
108+
109+
data, ok := req.ProviderData.(*CoderdProviderData)
110+
111+
if !ok {
112+
resp.Diagnostics.AddError(
113+
"Unexpected Data Source Configure Type",
114+
fmt.Sprintf("Expected *CoderdProviderData, got: %T. Please report this issue to the provider developers.", req.ProviderData),
115+
)
116+
117+
return
118+
}
119+
120+
d.data = data
121+
}
122+
123+
func (d *GroupDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) {
124+
var data GroupDataSourceModel
125+
126+
// Read Terraform configuration data into the model
127+
resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
128+
129+
if resp.Diagnostics.HasError() {
130+
return
131+
}
132+
133+
client := d.data.Client
134+
135+
var group codersdk.Group
136+
if !data.ID.IsNull() {
137+
138+
groupID, err := uuid.Parse(data.ID.ValueString())
139+
if err != nil {
140+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to parse supplied group ID as UUID, got error: %s", err))
141+
return
142+
}
143+
144+
group, err = client.Group(ctx, groupID)
145+
if err != nil {
146+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get group by ID, got error: %s", err))
147+
return
148+
}
149+
data.Name = types.StringValue(group.Name)
150+
data.OrganizationID = types.StringValue(group.OrganizationID.String())
151+
} else {
152+
orgID, err := uuid.Parse(data.OrganizationID.ValueString())
153+
if err != nil {
154+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to parse supplied organization ID as UUID, got error: %s", err))
155+
return
156+
}
157+
group, err = client.GroupByOrgAndName(ctx, orgID, data.Name.ValueString())
158+
if err != nil {
159+
resp.Diagnostics.AddError("Failed to get group by name and org ID", err.Error())
160+
return
161+
}
162+
data.ID = types.StringValue(group.ID.String())
163+
}
164+
165+
data.DisplayName = types.StringValue(group.DisplayName)
166+
data.AvatarURL = types.StringValue(group.AvatarURL)
167+
data.QuotaAllowance = types.Int32Value(int32(group.QuotaAllowance))
168+
members := make([]attr.Value, 0, len(group.Members))
169+
for _, member := range group.Members {
170+
members = append(members, types.StringValue(member.ID.String()))
171+
}
172+
data.Members = types.SetValueMust(types.StringType, members)
173+
data.Source = types.StringValue(string(group.Source))
174+
175+
// Save data into Terraform state
176+
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
177+
}
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
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_acc")
23+
firstUser, err := client.User(ctx, codersdk.Me)
24+
require.NoError(t, err)
25+
26+
user1, err := client.CreateUser(ctx, codersdk.CreateUserRequest{
27+
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+
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.TestCheckTypeSetElemAttr("data.coderd_group.test", "members.*", user1.ID.String()),
65+
resource.TestCheckTypeSetElemAttr("data.coderd_group.test", "members.*", user2.ID.String()),
66+
resource.TestCheckResourceAttr("data.coderd_group.test", "source", "user"),
67+
)
68+
69+
t.Run("GroupByIDOk", func(t *testing.T) {
70+
cfg := testAccGroupDataSourceConfig{
71+
URL: client.URL.String(),
72+
Token: client.SessionToken(),
73+
ID: PtrTo(group.ID.String()),
74+
}
75+
resource.Test(t, resource.TestCase{
76+
PreCheck: func() { testAccPreCheck(t) },
77+
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
78+
Steps: []resource.TestStep{
79+
{
80+
Config: cfg.String(t),
81+
Check: checkFn,
82+
},
83+
},
84+
})
85+
})
86+
87+
t.Run("GroupByNameAndOrganizationIDOk", func(t *testing.T) {
88+
cfg := testAccGroupDataSourceConfig{
89+
URL: client.URL.String(),
90+
Token: client.SessionToken(),
91+
OrganizationID: PtrTo(firstUser.OrganizationIDs[0].String()),
92+
Name: PtrTo("example-group"),
93+
}
94+
resource.Test(t, resource.TestCase{
95+
PreCheck: func() { testAccPreCheck(t) },
96+
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
97+
Steps: []resource.TestStep{
98+
{
99+
Config: cfg.String(t),
100+
Check: checkFn,
101+
},
102+
},
103+
})
104+
})
105+
106+
t.Run("GroupNameOnlyError", func(t *testing.T) {
107+
cfg := testAccGroupDataSourceConfig{
108+
URL: client.URL.String(),
109+
Token: client.SessionToken(),
110+
Name: PtrTo("example-group"),
111+
}
112+
resource.Test(t, resource.TestCase{
113+
PreCheck: func() { testAccPreCheck(t) },
114+
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
115+
// Neither ID nor Username
116+
Steps: []resource.TestStep{
117+
{
118+
Config: cfg.String(t),
119+
ExpectError: regexp.MustCompile(`Attribute "organization_id" must be specified when "name" is specified`),
120+
},
121+
},
122+
})
123+
})
124+
125+
t.Run("OrgIDOnlyError", func(t *testing.T) {
126+
cfg := testAccGroupDataSourceConfig{
127+
URL: client.URL.String(),
128+
Token: client.SessionToken(),
129+
OrganizationID: PtrTo(firstUser.OrganizationIDs[0].String()),
130+
}
131+
resource.Test(t, resource.TestCase{
132+
PreCheck: func() { testAccPreCheck(t) },
133+
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
134+
// Neither ID nor Username
135+
Steps: []resource.TestStep{
136+
{
137+
Config: cfg.String(t),
138+
ExpectError: regexp.MustCompile(`At least one attribute out of \[name,id\] must be specified`),
139+
},
140+
},
141+
})
142+
})
143+
144+
t.Run("NoneError", func(t *testing.T) {
145+
cfg := testAccGroupDataSourceConfig{
146+
URL: client.URL.String(),
147+
Token: client.SessionToken(),
148+
}
149+
resource.Test(t, resource.TestCase{
150+
PreCheck: func() { testAccPreCheck(t) },
151+
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
152+
// Neither ID nor Username
153+
Steps: []resource.TestStep{
154+
{
155+
Config: cfg.String(t),
156+
ExpectError: regexp.MustCompile(`At least one attribute out of \[name,id\] must be specified`),
157+
},
158+
},
159+
})
160+
})
161+
}
162+
163+
type testAccGroupDataSourceConfig struct {
164+
URL string
165+
Token string
166+
167+
ID *string
168+
Name *string
169+
OrganizationID *string
170+
}
171+
172+
func (c testAccGroupDataSourceConfig) String(t *testing.T) string {
173+
tpl := `
174+
provider coderd {
175+
url = "{{.URL}}"
176+
token = "{{.Token}}"
177+
}
178+
179+
data "coderd_group" "test" {
180+
id = {{orNull .ID}}
181+
name = {{orNull .Name}}
182+
organization_id = {{orNull .OrganizationID}}
183+
}
184+
`
185+
186+
funcMap := template.FuncMap{
187+
"orNull": PrintOrNull(t),
188+
}
189+
190+
buf := strings.Builder{}
191+
tmpl, err := template.New("groupDataSource").Funcs(funcMap).Parse(tpl)
192+
require.NoError(t, err)
193+
194+
err = tmpl.Execute(&buf, c)
195+
require.NoError(t, err)
196+
return buf.String()
197+
}

internal/provider/provider.go

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

111111
func (p *CoderdProvider) DataSources(ctx context.Context) []func() datasource.DataSource {
112112
return []func() datasource.DataSource{
113+
NewGroupDataSource,
113114
NewUserDataSource,
114115
}
115116
}

0 commit comments

Comments
 (0)