Skip to content

feat: add coderd_organization data source #33

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/data-sources/group.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ An existing group on the coder deployment.

- `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.
- `organization_id` (String) 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.

### Read-Only

Expand Down
28 changes: 28 additions & 0 deletions docs/data-sources/organization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
# generated by https://github.com/hashicorp/terraform-plugin-docs
page_title: "coderd_organization Data Source - coderd"
subcategory: ""
description: |-
An existing organization on the coder deployment.
---

# coderd_organization (Data Source)

An existing organization on the coder deployment.



<!-- schema generated by tfplugindocs -->
## Schema

### Optional

- `id` (String) The ID of the organization to retrieve. This field will be populated if the organization is found by name, or if the default organization is requested.
- `is_default` (Boolean) Whether the organization is the default organization of the deployment. This field will be populated if the organization is found by ID or name.
- `name` (String) The name of the organization to retrieve. This field will be populated if the organization is found by ID, or if the default organization is requested.

### Read-Only

- `created_at` (Number) Unix timestamp when the organization was created.
- `members` (Set of String) Members of the organization, by ID
- `updated_at` (Number) Unix timestamp when the organization was last updated.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ toolchain go1.22.5

require (
cdr.dev/slog v1.6.2-0.20240126064726-20367d4aede6
github.com/coder/coder/v2 v2.12.3
github.com/coder/coder/v2 v2.13.1
github.com/docker/docker v27.0.3+incompatible
github.com/docker/go-connections v0.4.0
github.com/google/uuid v1.6.0
Expand Down
6 changes: 4 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,10 @@ github.com/chenzhuoyu/iasm v0.9.0 h1:9fhXjVzq5hUy2gkhhgHl95zG2cEAhw9OSGs8toWWAwo
github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
github.com/coder/coder/v2 v2.12.3 h1:tA+0lWIO7xXJ4guu+tqcram/6kKKX1pWd1WlipdhIpc=
github.com/coder/coder/v2 v2.12.3/go.mod h1:io26dngPVP3a7zD1lL/bzEOGDSincJGomBKlqmRRVNA=
github.com/coder/coder/v2 v2.13.0 h1:MlkRGqQcCAdwIkLc9iV8sQfT4jB3EThHopG0jF3BuFE=
github.com/coder/coder/v2 v2.13.0/go.mod h1:Gxc79InMB6b+sncuDUORtFLWi7aKshvis3QrMUhpq5Q=
github.com/coder/coder/v2 v2.13.1 h1:tCd8ljqIAufbVcBr8ODS1QbsrjJbmOIvgDkvdd/JMXc=
github.com/coder/coder/v2 v2.13.1/go.mod h1:Gxc79InMB6b+sncuDUORtFLWi7aKshvis3QrMUhpq5Q=
github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 h1:3A0ES21Ke+FxEM8CXx9n47SZOKOpgSE1bbJzlE4qPVs=
github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0/go.mod h1:5UuS2Ts+nTToAMeOjNlnHFkPahrtDkmpydBen/3wgZc=
github.com/coder/serpent v0.7.0 h1:zGpD2GlF3lKIVkMjNGKbkip88qzd5r/TRcc30X/SrT0=
Expand Down
185 changes: 185 additions & 0 deletions internal/provider/organization_data_source.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
package provider

import (
"context"
"fmt"

"github.com/coder/coder/v2/codersdk"
"github.com/google/uuid"
"github.com/hashicorp/terraform-plugin-framework-validators/datasourcevalidator"
"github.com/hashicorp/terraform-plugin-framework/attr"
"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/types"
)

// Ensure provider defined types fully satisfy framework interfaces.
var _ datasource.DataSource = &OrganizationDataSource{}
var _ datasource.DataSourceWithConfigValidators = &OrganizationDataSource{}

func NewOrganizationDataSource() datasource.DataSource {
return &OrganizationDataSource{}
}

// OrganizationDataSource defines the data source implementation.
type OrganizationDataSource struct {
data *CoderdProviderData
}

// OrganizationDataSourceModel describes the data source data model.
type OrganizationDataSourceModel struct {
// Exactly one of ID, IsDefault, or Name must be set.
ID types.String `tfsdk:"id"`
IsDefault types.Bool `tfsdk:"is_default"`
Name types.String `tfsdk:"name"`

CreatedAt types.Int64 `tfsdk:"created_at"`
UpdatedAt types.Int64 `tfsdk:"updated_at"`
// TODO: This could reasonably store some User object - though we may need to make additional queries depending on what fields we
// want, or to have one consistent user type for all data sources.
Members types.Set `tfsdk:"members"`
}

func (d *OrganizationDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_organization"
}

func (d *OrganizationDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "An existing organization on the coder deployment.",

Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
MarkdownDescription: "The ID of the organization to retrieve. This field will be populated if the organization is found by name, or if the default organization is requested.",
Optional: true,
Computed: true,
},
"is_default": schema.BoolAttribute{
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think is_default is going to become irrelevant very shortly, so we should just stick to ID and name since those will stick around. When orgs get actually released I don't think there will be a "default org" anymore as the concept only exists for placing users into the only existing org.

For now you should just allow id = "default" (rather than name = "default")

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even though it'll be irrelevant shortly, we need to stick with it for the time being. Terraform will always complain if you try to set an attribute to a value that differs from what it was configured to. In this case, we'd need to have a special case to not write the actual ID of the org to the id attribute.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm just going to keep the is_default for now, and we'll end up finalizing it with the rest of the organization implementation.

MarkdownDescription: "Whether the organization is the default organization of the deployment. This field will be populated if the organization is found by ID or name.",
Optional: true,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made a comment on the UX of this in #34

Computed: true,
},
"name": schema.StringAttribute{
MarkdownDescription: "The name of the organization to retrieve. This field will be populated if the organization is found by ID, or if the default organization is requested.",
Optional: true,
Computed: true,
},
"created_at": schema.Int64Attribute{
MarkdownDescription: "Unix timestamp when the organization was created.",
Computed: true,
},
"updated_at": schema.Int64Attribute{
MarkdownDescription: "Unix timestamp when the organization was last updated.",
Computed: true,
},

"members": schema.SetAttribute{
MarkdownDescription: "Members of the organization, by ID",
Computed: true,
ElementType: types.StringType,
},
},
}
}

func (d *OrganizationDataSource) 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 *OrganizationDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) {
var data OrganizationDataSourceModel

// Read Terraform configuration data into the model
resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)

if resp.Diagnostics.HasError() {
return
}

client := d.data.Client

var org codersdk.Organization
if !data.ID.IsNull() { // By ID
orgID, err := uuid.Parse(data.ID.ValueString())
if err != nil {
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to parse supplied ID as UUID, got error: %s", err))
return
}
org, err = client.Organization(ctx, orgID)
if err != nil {
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get organization by ID, got error: %s", err))
return
}
if org.ID.String() != data.ID.ValueString() {
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Organization ID %s does not match requested ID %s", org.ID, data.ID))
return
}
} else if data.IsDefault.ValueBool() { // Get Default
var err error
org, err = client.OrganizationByName(ctx, "default")
if err != nil {
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get default organization, got error: %s", err))
return
}
if !org.IsDefault {
resp.Diagnostics.AddError("Client Error", "Found organization was not the default organization")
return
}
} else { // By Name
var err error
org, err = client.OrganizationByName(ctx, data.Name.ValueString())
if err != nil {
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get organization by name, got error: %s", err))
return
}
if org.Name != data.Name.ValueString() {
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Organization name %s does not match requested name %s", org.Name, data.Name))
return
}
}
data.ID = types.StringValue(org.ID.String())
data.Name = types.StringValue(org.Name)
data.IsDefault = types.BoolValue(org.IsDefault)
data.CreatedAt = types.Int64Value(org.CreatedAt.Unix())
data.UpdatedAt = types.Int64Value(org.UpdatedAt.Unix())
members, err := client.OrganizationMembers(ctx, org.ID)
if err != nil {
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get organization members, got error: %s", err))
return
}
memberIDs := make([]attr.Value, 0, len(members))
for _, member := range members {
memberIDs = append(memberIDs, types.StringValue(member.UserID.String()))
}
data.Members = types.SetValueMust(types.StringType, memberIDs)

// Save data into Terraform state
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}

func (d *OrganizationDataSource) ConfigValidators(_ context.Context) []datasource.ConfigValidator {
return []datasource.ConfigValidator{
datasourcevalidator.ExactlyOneOf(
path.MatchRoot("id"),
path.MatchRoot("is_default"),
path.MatchRoot("name"),
),
}
}
146 changes: 146 additions & 0 deletions internal/provider/organization_data_source_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
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 TestAccOrganizationDataSource(t *testing.T) {
if os.Getenv("TF_ACC") == "" {
t.Skip("Acceptance tests are disabled.")
}
ctx := context.Background()
client := integration.StartCoder(ctx, t, "group_acc", true)
firstUser, err := client.User(ctx, codersdk.Me)
require.NoError(t, err)

defaultCheckFn := resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("data.coderd_organization.test", "id", firstUser.OrganizationIDs[0].String()),
resource.TestCheckResourceAttr("data.coderd_organization.test", "is_default", "true"),
resource.TestCheckResourceAttr("data.coderd_organization.test", "name", "first-organization"),
resource.TestCheckResourceAttr("data.coderd_organization.test", "members.#", "1"),
resource.TestCheckTypeSetElemAttr("data.coderd_organization.test", "members.*", firstUser.ID.String()),
resource.TestCheckResourceAttrSet("data.coderd_organization.test", "created_at"),
resource.TestCheckResourceAttrSet("data.coderd_organization.test", "updated_at"),
)

t.Run("DefaultOrgByIDOk", func(t *testing.T) {
cfg := testAccOrganizationDataSourceConfig{
URL: client.URL.String(),
Token: client.SessionToken(),
ID: PtrTo(firstUser.OrganizationIDs[0].String()),
}
resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
{
Config: cfg.String(t),
Check: defaultCheckFn,
},
},
})
})

t.Run("DefaultOrgByNameOk", func(t *testing.T) {
cfg := testAccOrganizationDataSourceConfig{
URL: client.URL.String(),
Token: client.SessionToken(),
Name: PtrTo("first-organization"),
}
resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
{
Config: cfg.String(t),
Check: defaultCheckFn,
},
},
})
})

t.Run("DefaultOrgByIsDefaultOk", func(t *testing.T) {
cfg := testAccOrganizationDataSourceConfig{
URL: client.URL.String(),
Token: client.SessionToken(),
IsDefault: PtrTo(true),
}
resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
{
Config: cfg.String(t),
Check: defaultCheckFn,
},
},
})
})

t.Run("InvalidAttributesError", func(t *testing.T) {
cfg := testAccOrganizationDataSourceConfig{
URL: client.URL.String(),
Token: client.SessionToken(),
IsDefault: PtrTo(true),
Name: PtrTo("first-organization"),
}
resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
{
Config: cfg.String(t),
ExpectError: regexp.MustCompile(`Exactly one of these attributes must be configured: \[id,is\_default,name\]`),
},
},
})
})

// TODO: Non-default org tests
}

type testAccOrganizationDataSourceConfig struct {
URL string
Token string

ID *string
Name *string
IsDefault *bool
}

func (c testAccOrganizationDataSourceConfig) String(t *testing.T) string {
tpl := `
provider coderd {
url = "{{.URL}}"
token = "{{.Token}}"
}

data "coderd_organization" "test" {
id = {{orNull .ID}}
name = {{orNull .Name}}
is_default = {{orNull .IsDefault}}
}
`

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()
}
Loading
Loading