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..5ed8dc2 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.0 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..beb75bb 100644 --- a/go.sum +++ b/go.sum @@ -81,8 +81,8 @@ 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/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..8c95915 --- /dev/null +++ b/internal/provider/organization_data_source.go @@ -0,0 +1,183 @@ +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"` + 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, } }