From 62ed2be0caf549e42247d288a0736a3e0d9586bd Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Wed, 17 Jul 2024 06:09:58 +0000 Subject: [PATCH] feat: add coderd_group data source --- docs/data-sources/group.md | 44 ++++ internal/provider/group_data_source.go | 225 ++++++++++++++++++++ internal/provider/group_data_source_test.go | 200 +++++++++++++++++ internal/provider/provider.go | 1 + 4 files changed, 470 insertions(+) create mode 100644 docs/data-sources/group.md create mode 100644 internal/provider/group_data_source.go create mode 100644 internal/provider/group_data_source_test.go diff --git a/docs/data-sources/group.md b/docs/data-sources/group.md new file mode 100644 index 0000000..ab66565 --- /dev/null +++ b/docs/data-sources/group.md @@ -0,0 +1,44 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "coderd_group Data Source - coderd" +subcategory: "" +description: |- + An existing group on the coder deployment. +--- + +# coderd_group (Data Source) + +An existing group on the coder deployment. + + + + +## Schema + +### Optional + +- `id` (String) The ID of the group to retrieve. This field will be populated if a name and organization ID is supplied. +- `name` (String) The name of the group to retrieve. This field will be populated if an ID is supplied. +- `organization_id` (String) The organization ID that the group belongs to. This field will be populated if an ID is supplied. + +### Read-Only + +- `avatar_url` (String) +- `display_name` (String) +- `members` (Attributes Set) Members of the group. (see [below for nested schema](#nestedatt--members)) +- `quota_allowance` (Number) The number of quota credits to allocate to each user in the group. +- `source` (String) The source of the group. Either 'oidc' or 'user'. + + +### Nested Schema for `members` + +Read-Only: + +- `created_at` (Number) Unix timestamp of when the member was created. +- `email` (String) +- `id` (String) +- `last_seen_at` (Number) Unix timestamp of when the member was last seen. +- `login_type` (String) The login type of the member. Can be 'oidc', 'token', 'password', 'github' or 'none'. +- `status` (String) The status of the member. Can be 'active', 'dormant' or 'suspended'. +- `theme_preference` (String) +- `username` (String) diff --git a/internal/provider/group_data_source.go b/internal/provider/group_data_source.go new file mode 100644 index 0000000..62e1cbf --- /dev/null +++ b/internal/provider/group_data_source.go @@ -0,0 +1,225 @@ +package provider + +import ( + "context" + "fmt" + + "github.com/coder/coder/v2/codersdk" + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// Ensure provider defined types fully satisfy framework interfaces. +var _ datasource.DataSource = &GroupDataSource{} + +func NewGroupDataSource() datasource.DataSource { + return &GroupDataSource{} +} + +// GroupDataSource defines the data source implementation. +type GroupDataSource struct { + data *CoderdProviderData +} + +// GroupDataSourceModel describes the data source data model. +type GroupDataSourceModel struct { + // ID or name and organization ID must be set + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + OrganizationID types.String `tfsdk:"organization_id"` + + DisplayName types.String `tfsdk:"display_name"` + AvatarURL types.String `tfsdk:"avatar_url"` + QuotaAllowance types.Int32 `tfsdk:"quota_allowance"` + Source types.String `tfsdk:"source"` + Members []Member `tfsdk:"members"` +} + +type Member struct { + ID types.String `tfsdk:"id"` + Username types.String `tfsdk:"username"` + Email types.String `tfsdk:"email"` + CreatedAt types.Int64 `tfsdk:"created_at"` + LastSeenAt types.Int64 `tfsdk:"last_seen_at"` + Status types.String `tfsdk:"status"` + LoginType types.String `tfsdk:"login_type"` + ThemePreference types.String `tfsdk:"theme_preference"` +} + +func (d *GroupDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_group" +} + +func (d *GroupDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "An existing group on the coder deployment.", + + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + MarkdownDescription: "The ID of the group to retrieve. This field will be populated if a name and organization ID is supplied.", + Optional: true, + Computed: true, + Validators: []validator.String{ + stringvalidator.AtLeastOneOf(path.Expressions{ + path.MatchRoot("name"), + }...), + }, + }, + "name": schema.StringAttribute{ + MarkdownDescription: "The name of the group to retrieve. This field will be populated if an ID is supplied.", + Optional: true, + Computed: true, + Validators: []validator.String{}, + }, + "organization_id": schema.StringAttribute{ + 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.", + Optional: true, + Computed: true, + }, + "display_name": schema.StringAttribute{ + Computed: true, + }, + "avatar_url": schema.StringAttribute{ + Computed: true, + }, + "quota_allowance": schema.Int32Attribute{ + MarkdownDescription: "The number of quota credits to allocate to each user in the group.", + Computed: true, + }, + "source": schema.StringAttribute{ + MarkdownDescription: "The source of the group. Either 'oidc' or 'user'.", + Computed: true, + }, + "members": schema.SetNestedAttribute{ + MarkdownDescription: "Members of the group.", + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + }, + "username": schema.StringAttribute{ + Computed: true, + }, + "email": schema.StringAttribute{ + Computed: true, + }, + "created_at": schema.Int64Attribute{ + MarkdownDescription: "Unix timestamp of when the member was created.", + Computed: true, + }, + "last_seen_at": schema.Int64Attribute{ + MarkdownDescription: "Unix timestamp of when the member was last seen.", + Computed: true, + }, + "status": schema.StringAttribute{ + MarkdownDescription: "The status of the member. Can be 'active', 'dormant' or 'suspended'.", + Computed: true, + }, + "login_type": schema.StringAttribute{ + MarkdownDescription: "The login type of the member. Can be 'oidc', 'token', 'password', 'github' or 'none'.", + Computed: true, + }, + "theme_preference": schema.StringAttribute{ + Computed: true, + }, + // TODO: Upgrade requested user type if required + }, + }, + }, + }, + } +} + +func (d *GroupDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + data, ok := req.ProviderData.(*CoderdProviderData) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected *CoderdProviderData, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + d.data = data +} + +func (d *GroupDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var data GroupDataSourceModel + + // Read Terraform configuration data into the model + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + client := d.data.Client + + if data.OrganizationID.IsNull() { + data.OrganizationID = types.StringValue(d.data.DefaultOrganizationID) + } + + var group codersdk.Group + if !data.ID.IsNull() { + groupID, err := uuid.Parse(data.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to parse supplied group ID as UUID, got error: %s", err)) + return + } + + group, err = client.Group(ctx, groupID) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get group by ID, got error: %s", err)) + return + } + data.Name = types.StringValue(group.Name) + data.OrganizationID = types.StringValue(group.OrganizationID.String()) + } else { + orgID, err := uuid.Parse(data.OrganizationID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to parse supplied organization ID as UUID, got error: %s", err)) + return + } + group, err = client.GroupByOrgAndName(ctx, orgID, data.Name.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Failed to get group by name and org ID", err.Error()) + return + } + data.ID = types.StringValue(group.ID.String()) + } + + data.DisplayName = types.StringValue(group.DisplayName) + data.AvatarURL = types.StringValue(group.AvatarURL) + data.QuotaAllowance = types.Int32Value(int32(group.QuotaAllowance)) + members := make([]Member, 0, len(group.Members)) + for _, member := range group.Members { + members = append(members, Member{ + ID: types.StringValue(member.ID.String()), + Username: types.StringValue(member.Username), + Email: types.StringValue(member.Email), + CreatedAt: types.Int64Value(member.CreatedAt.Unix()), + LastSeenAt: types.Int64Value(member.LastSeenAt.Unix()), + Status: types.StringValue(string(member.Status)), + LoginType: types.StringValue(string(member.LoginType)), + ThemePreference: types.StringValue(member.ThemePreference), + }) + } + data.Members = members + data.Source = types.StringValue(string(group.Source)) + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} diff --git a/internal/provider/group_data_source_test.go b/internal/provider/group_data_source_test.go new file mode 100644 index 0000000..349e855 --- /dev/null +++ b/internal/provider/group_data_source_test.go @@ -0,0 +1,200 @@ +package provider + +import ( + "context" + "os" + "regexp" + "strings" + "testing" + "text/template" + + "github.com/coder/coder/v2/codersdk" + "github.com/coder/terraform-provider-coderd/integration" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/stretchr/testify/require" +) + +func TestAccGroupDataSource(t *testing.T) { + if os.Getenv("TF_ACC") == "" { + t.Skip("Acceptance tests are disabled.") + } + ctx := context.Background() + client := integration.StartCoder(ctx, t, "group_data_acc", true) + firstUser, err := client.User(ctx, codersdk.Me) + require.NoError(t, err) + + user1, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ + Email: "example@coder.com", + Username: "example", + Password: "SomeSecurePassword!", + UserLoginType: "password", + OrganizationID: firstUser.OrganizationIDs[0], + }) + require.NoError(t, err) + + user2, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ + Email: "example2@coder.com", + Username: "example2", + Password: "SomeSecurePassword!", + UserLoginType: "password", + OrganizationID: firstUser.OrganizationIDs[0], + }) + require.NoError(t, err) + + group, err := client.CreateGroup(ctx, firstUser.OrganizationIDs[0], codersdk.CreateGroupRequest{ + Name: "example-group", + DisplayName: "Example Group", + AvatarURL: "https://google.com", + QuotaAllowance: 10, + }) + require.NoError(t, err) + group, err = client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{user1.ID.String(), user2.ID.String()}, + }) + require.NoError(t, err) + + checkFn := resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("data.coderd_group.test", "id", group.ID.String()), + resource.TestCheckResourceAttr("data.coderd_group.test", "name", "example-group"), + resource.TestCheckResourceAttr("data.coderd_group.test", "organization_id", firstUser.OrganizationIDs[0].String()), + resource.TestCheckResourceAttr("data.coderd_group.test", "display_name", "Example Group"), + resource.TestCheckResourceAttr("data.coderd_group.test", "avatar_url", "https://google.com"), + resource.TestCheckResourceAttr("data.coderd_group.test", "quota_allowance", "10"), + resource.TestCheckResourceAttr("data.coderd_group.test", "members.#", "2"), + resource.TestCheckTypeSetElemNestedAttrs("data.coderd_group.test", "members.*", map[string]string{ + "id": user1.ID.String(), + }), + resource.TestCheckTypeSetElemNestedAttrs("data.coderd_group.test", "members.*", map[string]string{ + "id": user2.ID.String(), + }), + resource.TestCheckResourceAttr("data.coderd_group.test", "source", "user"), + ) + + t.Run("GroupByIDOk", func(t *testing.T) { + cfg := testAccGroupDataSourceConfig{ + URL: client.URL.String(), + Token: client.SessionToken(), + ID: PtrTo(group.ID.String()), + } + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: cfg.String(t), + Check: checkFn, + }, + }, + }) + }) + + t.Run("GroupByNameAndOrganizationIDOk", func(t *testing.T) { + cfg := testAccGroupDataSourceConfig{ + URL: client.URL.String(), + Token: client.SessionToken(), + OrganizationID: PtrTo(firstUser.OrganizationIDs[0].String()), + Name: PtrTo("example-group"), + } + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: cfg.String(t), + Check: checkFn, + }, + }, + }) + }) + + t.Run("UseDefaultOrganizationIDOk", func(t *testing.T) { + cfg := testAccGroupDataSourceConfig{ + URL: client.URL.String(), + Token: client.SessionToken(), + Name: PtrTo("example-group"), + } + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: cfg.String(t), + Check: checkFn, + }, + }, + }) + }) + + t.Run("OrgIDOnlyError", func(t *testing.T) { + cfg := testAccGroupDataSourceConfig{ + URL: client.URL.String(), + Token: client.SessionToken(), + OrganizationID: PtrTo(firstUser.OrganizationIDs[0].String()), + } + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + // Neither ID nor Username + Steps: []resource.TestStep{ + { + Config: cfg.String(t), + ExpectError: regexp.MustCompile(`At least one attribute out of \[name,id\] must be specified`), + }, + }, + }) + }) + + t.Run("NoneError", func(t *testing.T) { + cfg := testAccGroupDataSourceConfig{ + URL: client.URL.String(), + Token: client.SessionToken(), + } + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + // Neither ID nor Username + Steps: []resource.TestStep{ + { + Config: cfg.String(t), + ExpectError: regexp.MustCompile(`At least one attribute out of \[name,id\] must be specified`), + }, + }, + }) + }) +} + +type testAccGroupDataSourceConfig struct { + URL string + Token string + + ID *string + Name *string + OrganizationID *string +} + +func (c testAccGroupDataSourceConfig) String(t *testing.T) string { + tpl := ` +provider coderd { + url = "{{.URL}}" + token = "{{.Token}}" +} + +data "coderd_group" "test" { + id = {{orNull .ID}} + name = {{orNull .Name}} + organization_id = {{orNull .OrganizationID}} +} +` + + funcMap := template.FuncMap{ + "orNull": PrintOrNull, + } + + buf := strings.Builder{} + tmpl, err := template.New("groupDataSource").Funcs(funcMap).Parse(tpl) + require.NoError(t, err) + + err = tmpl.Execute(&buf, c) + require.NoError(t, err) + return buf.String() +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 8cfe4c7..610921e 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -128,6 +128,7 @@ func (p *CoderdProvider) Resources(ctx context.Context) []func() resource.Resour func (p *CoderdProvider) DataSources(ctx context.Context) []func() datasource.DataSource { return []func() datasource.DataSource{ + NewGroupDataSource, NewUserDataSource, } }