Skip to content

Commit ebb86ba

Browse files
committed
feat: add coderd_group data source
1 parent 0eade27 commit ebb86ba

File tree

3 files changed

+376
-0
lines changed

3 files changed

+376
-0
lines changed
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
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+
// Validation handled by ConfigValidators
53+
Attributes: map[string]schema.Attribute{
54+
"id": schema.StringAttribute{
55+
MarkdownDescription: "The ID of the group to retrieve. This field will be populated if a name and organisation ID is supplied.",
56+
Optional: true,
57+
Computed: true,
58+
Validators: []validator.String{
59+
stringvalidator.AtLeastOneOf(path.Expressions{
60+
path.MatchRoot("name"),
61+
}...),
62+
},
63+
},
64+
"name": schema.StringAttribute{
65+
MarkdownDescription: "The name of the group to retrieve. This field will be populated if an ID is supplied.",
66+
Optional: true,
67+
Computed: true,
68+
Validators: []validator.String{
69+
stringvalidator.AlsoRequires(path.Expressions{
70+
path.MatchRoot("organization_id"),
71+
}...),
72+
},
73+
},
74+
"organization_id": schema.StringAttribute{
75+
MarkdownDescription: "The organization ID that the group belongs to. This field will be populated if an ID is supplied.",
76+
Optional: true,
77+
Computed: true,
78+
},
79+
"display_name": schema.StringAttribute{
80+
MarkdownDescription: "The display name of the group.",
81+
Computed: true,
82+
},
83+
"avatar_url": schema.StringAttribute{
84+
MarkdownDescription: "The URL of the group's avatar.",
85+
Computed: true,
86+
},
87+
"quota_allowance": schema.Int32Attribute{
88+
MarkdownDescription: "The number of quota credits to allocate to each user in the group.",
89+
Computed: true,
90+
},
91+
"members": schema.SetAttribute{
92+
MarkdownDescription: "Members of the group, by ID.",
93+
ElementType: types.StringType,
94+
Computed: true,
95+
},
96+
"source": schema.StringAttribute{
97+
MarkdownDescription: "The source of the group. Either 'oidc' or 'user'.",
98+
Computed: true,
99+
},
100+
},
101+
}
102+
}
103+
104+
func (d *GroupDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
105+
// Prevent panic if the provider has not been configured.
106+
if req.ProviderData == nil {
107+
return
108+
}
109+
110+
data, ok := req.ProviderData.(*CoderdProviderData)
111+
112+
if !ok {
113+
resp.Diagnostics.AddError(
114+
"Unexpected Data Source Configure Type",
115+
fmt.Sprintf("Expected *CoderdProviderData, got: %T. Please report this issue to the provider developers.", req.ProviderData),
116+
)
117+
118+
return
119+
}
120+
121+
d.data = data
122+
}
123+
124+
func (d *GroupDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) {
125+
var data GroupDataSourceModel
126+
127+
// Read Terraform configuration data into the model
128+
resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
129+
130+
if resp.Diagnostics.HasError() {
131+
return
132+
}
133+
134+
client := d.data.Client
135+
136+
var group codersdk.Group
137+
if !data.ID.IsNull() {
138+
139+
groupID, err := uuid.Parse(data.ID.ValueString())
140+
if err != nil {
141+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to parse supplied group ID as UUID, got error: %s", err))
142+
return
143+
}
144+
145+
group, err = client.Group(ctx, groupID)
146+
if err != nil {
147+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get group by ID, got error: %s", err))
148+
return
149+
}
150+
data.Name = types.StringValue(group.Name)
151+
data.OrganizationID = types.StringValue(group.OrganizationID.String())
152+
} else {
153+
orgID, err := uuid.Parse(data.OrganizationID.ValueString())
154+
if err != nil {
155+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to parse supplied organization ID as UUID, got error: %s", err))
156+
return
157+
}
158+
group, err = client.GroupByOrgAndName(ctx, orgID, data.Name.ValueString())
159+
if err != nil {
160+
resp.Diagnostics.AddError("Failed to get group by name and org ID", err.Error())
161+
return
162+
}
163+
data.ID = types.StringValue(group.ID.String())
164+
}
165+
166+
data.DisplayName = types.StringValue(group.DisplayName)
167+
data.AvatarURL = types.StringValue(group.AvatarURL)
168+
data.QuotaAllowance = types.Int32Value(int32(group.QuotaAllowance))
169+
members := make([]attr.Value, 0, len(group.Members))
170+
for _, member := range group.Members {
171+
members = append(members, types.StringValue(member.ID.String()))
172+
}
173+
data.Members = types.SetValueMust(types.StringType, members)
174+
data.Source = types.StringValue(string(group.Source))
175+
176+
// Save data into Terraform state
177+
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
178+
}
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)