diff --git a/docs/data-sources/group.md b/docs/data-sources/group.md index ab66565..419f195 100644 --- a/docs/data-sources/group.md +++ b/docs/data-sources/group.md @@ -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 diff --git a/docs/data-sources/organization.md b/docs/data-sources/organization.md new file mode 100644 index 0000000..16059e3 --- /dev/null +++ b/docs/data-sources/organization.md @@ -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 + +### 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. diff --git a/go.mod b/go.mod index 6abae49..c3fb310 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index c87f90c..55d5f08 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/provider/organization_data_source.go b/internal/provider/organization_data_source.go new file mode 100644 index 0000000..d57f68a --- /dev/null +++ b/internal/provider/organization_data_source.go @@ -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{ + 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, + 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"), + ), + } +} diff --git a/internal/provider/organization_data_source_test.go b/internal/provider/organization_data_source_test.go new file mode 100644 index 0000000..d4865c6 --- /dev/null +++ b/internal/provider/organization_data_source_test.go @@ -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() +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 610921e..1b67191 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -130,6 +130,7 @@ func (p *CoderdProvider) DataSources(ctx context.Context) []func() datasource.Da return []func() datasource.DataSource{ NewGroupDataSource, NewUserDataSource, + NewOrganizationDataSource, } }
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: