From 8b73a3e10127964ea1e148a086be00c4de639f0b Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Tue, 5 Nov 2024 22:56:20 +0000 Subject: [PATCH 01/25] add organization resource --- internal/provider/organization_resource.go | 94 ++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 internal/provider/organization_resource.go diff --git a/internal/provider/organization_resource.go b/internal/provider/organization_resource.go new file mode 100644 index 0000000..ad33d88 --- /dev/null +++ b/internal/provider/organization_resource.go @@ -0,0 +1,94 @@ +package provider + +import ( + "context" + "fmt" + + "github.com/coder/terraform-provider-coderd/internal" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// Ensure provider defined types fully satisfy framework interfaces. +var _ resource.Resource = &OrganizationResource{} + +type OrganizationResource struct { + data *CoderdProviderData +} + +func NewOrganizationResource() resource.Resource { + return &OrganizationResource{} +} + +func (r *OrganizationResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_organization" +} + +func (r *OrganizationResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "An organization on the Coder deployment", + + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + MarkdownDescription: "Username of the user.", + Required: true, + Validators: []validator.String{ + stringvalidator.LengthBetween(1, 32), + stringvalidator.RegexMatches(nameValidRegex, "Username must be alphanumeric with hyphens."), + }, + }, + "name": schema.StringAttribute{ + MarkdownDescription: "Display name of the user. Defaults to username.", + Computed: true, + Optional: true, + Validators: []validator.String{ + stringvalidator.LengthBetween(1, 128), + }, + }, + + "id": schema.StringAttribute{ + CustomType: internal.UUIDType, + Computed: true, + MarkdownDescription: "Organization ID", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, + } +} + +func (r *OrganizationResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.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( + "Unable to configure provider data", + fmt.Sprintf("Expected *CoderdProviderData, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + r.data = data +} + +func (r *OrganizationResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { +} +func (r *OrganizationResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { +} +func (r *OrganizationResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { +} +func (r *OrganizationResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { +} +func (r *OrganizationResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { +} From 4743b9ec37b03f3eec1d3072ae57b0d50d568447 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Thu, 7 Nov 2024 23:26:16 +0000 Subject: [PATCH 02/25] flesh out the organization resource --- internal/provider/organization_resource.go | 272 +++++++++++++++++++-- internal/provider/util.go | 8 +- 2 files changed, 256 insertions(+), 24 deletions(-) diff --git a/internal/provider/organization_resource.go b/internal/provider/organization_resource.go index ad33d88..14b7a8f 100644 --- a/internal/provider/organization_resource.go +++ b/internal/provider/organization_resource.go @@ -4,20 +4,39 @@ import ( "context" "fmt" - "github.com/coder/terraform-provider-coderd/internal" - "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/coder/coder/v2/coderd/util/slice" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/terraform-provider-coderd/internal/codersdkvalidator" + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" ) // Ensure provider defined types fully satisfy framework interfaces. var _ resource.Resource = &OrganizationResource{} +var _ resource.ResourceWithImportState = &OrganizationResource{} type OrganizationResource struct { - data *CoderdProviderData + *CoderdProviderData +} + +// OrganizationResourceModel describes the resource data model. +type OrganizationResourceModel struct { + ID UUID `tfsdk:"id"` + + Name types.String `tfsdk:"name"` + DisplayName types.String `tfsdk:"display_name"` + Description types.String `tfsdk:"description"` + Icon types.String `tfsdk:"icon"` + Members types.Set `tfsdk:"members"` } func NewOrganizationResource() resource.Resource { @@ -33,30 +52,43 @@ func (r *OrganizationResource) Schema(ctx context.Context, req resource.SchemaRe MarkdownDescription: "An organization on the Coder deployment", Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + CustomType: UUIDType, + Computed: true, + MarkdownDescription: "Organization ID", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, "name": schema.StringAttribute{ - MarkdownDescription: "Username of the user.", + MarkdownDescription: "Username of the organization.", Required: true, Validators: []validator.String{ - stringvalidator.LengthBetween(1, 32), - stringvalidator.RegexMatches(nameValidRegex, "Username must be alphanumeric with hyphens."), + codersdkvalidator.Name(), }, }, - "name": schema.StringAttribute{ - MarkdownDescription: "Display name of the user. Defaults to username.", + "display_name": schema.StringAttribute{ + MarkdownDescription: "Display name of the organization. Defaults to name.", Computed: true, Optional: true, Validators: []validator.String{ - stringvalidator.LengthBetween(1, 128), + codersdkvalidator.DisplayName(), }, }, - - "id": schema.StringAttribute{ - CustomType: internal.UUIDType, - Computed: true, - MarkdownDescription: "Organization ID", - PlanModifiers: []planmodifier.String{ - stringplanmodifier.UseStateForUnknown(), - }, + "description": schema.StringAttribute{ + Optional: true, + Computed: true, + Default: stringdefault.StaticString(""), + }, + "icon": schema.StringAttribute{ + Optional: true, + Computed: true, + Default: stringdefault.StaticString(""), + }, + "members": schema.SetAttribute{ + MarkdownDescription: "Members of the organization, by ID. If null, members will not be added or removed by Terraform.", + ElementType: UUIDType, + Optional: true, }, }, } @@ -79,16 +111,216 @@ func (r *OrganizationResource) Configure(ctx context.Context, req resource.Confi return } - r.data = data + r.CoderdProviderData = data } func (r *OrganizationResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + // Read Terraform prior state data into the model + var data OrganizationResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + orgID := data.ID.ValueUUID() + org, err := r.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 + } + + // We've fetched the organization ID from state, and the latest values for + // everything else from the backend. Ensure that any mutable data is synced + // with the backend. + data.Name = types.StringValue(org.Name) + data.DisplayName = types.StringValue(org.DisplayName) + data.Description = types.StringValue(org.Description) + data.Icon = types.StringValue(org.Icon) + if !data.Members.IsNull() { + members, err := r.Client.OrganizationMembers(ctx, orgID) + 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, UUIDValue(member.UserID)) + } + data.Members = types.SetValueMust(UUIDType, memberIDs) + } + + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } -func (r *OrganizationResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { -} + func (r *OrganizationResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + // Read Terraform plan data into the model + var data OrganizationResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Trace(ctx, "creating organization") + org, err := r.Client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ + Name: data.Name.ValueString(), + DisplayName: data.DisplayName.ValueString(), + Description: data.Description.ValueString(), + Icon: data.Icon.ValueString(), + }) + if err != nil { + resp.Diagnostics.AddError("Failed to create organization", err.Error()) + return + } + tflog.Trace(ctx, "successfully created organization", map[string]any{ + "id": org.ID, + }) + // Fill in `ID` since it must be "computed". + data.ID = UUIDValue(org.ID) + // We also fill in `DisplayName`, since it's optional but the backend will + // default it. + data.DisplayName = types.StringValue(org.DisplayName) + + // Only configure members if they're specified + if !data.Members.IsNull() { + tflog.Trace(ctx, "setting organization members") + var members []UUID + resp.Diagnostics.Append(data.Members.ElementsAs(ctx, &members, false)...) + if resp.Diagnostics.HasError() { + return + } + + for _, memberID := range members { + _, err = r.Client.PostOrganizationMember(ctx, org.ID, memberID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to add member %s to organization %s, got error: %s", memberID, org.ID, err)) + return + } + } + + // Coder adds the user who creates the organization by default, but we may + // actually be connected as a user who isn't in the list of members. If so + // we should remove them! + me, err := r.Client.User(ctx, codersdk.Me) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get current user, got error: %s", err)) + return + } + if slice.Contains(members, UUIDValue(me.ID)) { + err = r.Client.DeleteOrganizationMember(ctx, org.ID, codersdk.Me) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete self from new organization: %s", err)) + return + } + } + + tflog.Trace(ctx, "successfully set organization members") + } + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } + func (r *OrganizationResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + // Read Terraform plan data into the model + var data OrganizationResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + orgID := data.ID.ValueUUID() + + // Update the organization metadata + tflog.Trace(ctx, "updating organization", map[string]any{ + "id": orgID, + "new_name": data.Name, + "new_display_name": data.DisplayName, + "new_description": data.Description, + "new_icon": data.Icon, + }) + _, err := r.Client.UpdateOrganization(ctx, orgID.String(), codersdk.UpdateOrganizationRequest{ + Name: data.Name.ValueString(), + DisplayName: data.DisplayName.ValueString(), + Description: data.Description.ValueStringPointer(), + Icon: data.Icon.ValueStringPointer(), + }) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update organization %s, got error: %s", orgID, err)) + return + } + tflog.Trace(ctx, "successfully updated organization") + + // If the organization membership is managed, update them. + if !data.Members.IsNull() { + orgMembers, err := r.Client.OrganizationMembers(ctx, orgID) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get organization members , got error: %s", err)) + return + } + currentMembers := make([]uuid.UUID, 0, len(orgMembers)) + for _, member := range orgMembers { + currentMembers = append(currentMembers, member.UserID) + } + + var plannedMembers []UUID + resp.Diagnostics.Append(data.Members.ElementsAs(ctx, &plannedMembers, false)...) + if resp.Diagnostics.HasError() { + return + } + + add, remove := memberDiff(currentMembers, plannedMembers) + tflog.Trace(ctx, "updating organization members", map[string]any{ + "new_members": add, + "removed_members": remove, + }) + for _, memberID := range add { + _, err := r.Client.PostOrganizationMember(ctx, orgID, memberID) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to add member %s to organization %s, got error: %s", memberID, orgID, err)) + return + } + } + for _, memberID := range remove { + err := r.Client.DeleteOrganizationMember(ctx, orgID, memberID) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to remove member %s from organization %s, got error: %s", memberID, orgID, err)) + return + } + } + tflog.Trace(ctx, "successfully updated organization members") + } + + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } + func (r *OrganizationResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + // Read Terraform prior state data into the model + var data OrganizationResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + orgID := data.ID.ValueUUID() + + tflog.Trace(ctx, "deleting organization", map[string]any{ + "id": orgID, + }) + err := r.Client.DeleteOrganization(ctx, orgID.String()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete organization %s, got error: %s", orgID, err)) + return + } + tflog.Trace(ctx, "successfully deleted organization") + + // Read Terraform prior state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) +} + +func (r *OrganizationResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + // Terraform will eventually `Read` in the rest of the fields after we have + // set the `id` attribute. + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) } diff --git a/internal/provider/util.go b/internal/provider/util.go index 720259c..169286f 100644 --- a/internal/provider/util.go +++ b/internal/provider/util.go @@ -85,11 +85,11 @@ func computeDirectoryHash(directory string) (string, error) { // memberDiff returns the members to add and remove from the group, given the current members and the planned members. // plannedMembers is deliberately our custom type, as Terraform cannot automatically produce `[]uuid.UUID` from a set. -func memberDiff(curMembers []uuid.UUID, plannedMembers []UUID) (add, remove []string) { - curSet := make(map[uuid.UUID]struct{}, len(curMembers)) +func memberDiff(currentMembers []uuid.UUID, plannedMembers []UUID) (add, remove []string) { + curSet := make(map[uuid.UUID]struct{}, len(currentMembers)) planSet := make(map[uuid.UUID]struct{}, len(plannedMembers)) - for _, userID := range curMembers { + for _, userID := range currentMembers { curSet[userID] = struct{}{} } for _, plannedUserID := range plannedMembers { @@ -98,7 +98,7 @@ func memberDiff(curMembers []uuid.UUID, plannedMembers []UUID) (add, remove []st add = append(add, plannedUserID.ValueString()) } } - for _, curUserID := range curMembers { + for _, curUserID := range currentMembers { if _, exists := planSet[curUserID]; !exists { remove = append(remove, curUserID.String()) } From 435032b2a0475e87b840103068a39a27dfe41a60 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Thu, 7 Nov 2024 23:34:21 +0000 Subject: [PATCH 03/25] register new resource type + gen --- docs/resources/organization.md | 31 +++++++++++++++++++++++++++++++ internal/provider/provider.go | 1 + 2 files changed, 32 insertions(+) create mode 100644 docs/resources/organization.md diff --git a/docs/resources/organization.md b/docs/resources/organization.md new file mode 100644 index 0000000..e284875 --- /dev/null +++ b/docs/resources/organization.md @@ -0,0 +1,31 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "coderd_organization Resource - terraform-provider-coderd" +subcategory: "" +description: |- + An organization on the Coder deployment +--- + +# coderd_organization (Resource) + +An organization on the Coder deployment + + + + +## Schema + +### Required + +- `name` (String) Username of the organization. + +### Optional + +- `description` (String) +- `display_name` (String) Display name of the organization. Defaults to name. +- `icon` (String) +- `members` (Set of String) Members of the organization, by ID. If null, members will not be added or removed by Terraform. + +### Read-Only + +- `id` (String) Organization ID diff --git a/internal/provider/provider.go b/internal/provider/provider.go index bfeea5e..cc79997 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -139,6 +139,7 @@ func (p *CoderdProvider) Resources(ctx context.Context) []func() resource.Resour NewTemplateResource, NewWorkspaceProxyResource, NewLicenseResource, + NewOrganizationResource, } } From 71dc51bc554202dc389ce5fa9d46389897192840 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Fri, 8 Nov 2024 17:23:48 +0000 Subject: [PATCH 04/25] start with tests from ethan --- .../provider/organization_resource_test.go | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 internal/provider/organization_resource_test.go diff --git a/internal/provider/organization_resource_test.go b/internal/provider/organization_resource_test.go new file mode 100644 index 0000000..df9bcb3 --- /dev/null +++ b/internal/provider/organization_resource_test.go @@ -0,0 +1,164 @@ +package provider + +import ( + "context" + "os" + "strings" + "testing" + "text/template" + + "github.com/coder/coder/v2/coderd/util/ptr" + "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 TestAccOrganizationResource(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) + + 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) + + cfg1 := testAccOrganizationResourceConfig{ + URL: client.URL.String(), + Token: client.SessionToken(), + Name: ptr.Ref("example-org"), + DisplayName: ptr.Ref("Example Organization"), + Description: ptr.Ref("This is an example organization"), + Icon: ptr.Ref("/icon/coder.svg"), + Members: ptr.Ref([]string{user1.ID.String()}), + } + + cfg2 := cfg1 + cfg2.Name = ptr.Ref("example-org-new") + cfg2.DisplayName = ptr.Ref("Example Organization New") + cfg2.Members = ptr.Ref([]string{user2.ID.String()}) + + cfg3 := cfg2 + cfg3.Members = nil + + t.Run("CreateImportUpdateReadOk", func(t *testing.T) { + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Create and Read + { + Config: cfg1.String(t), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("coderd_organization.test", "name", "example-org"), + resource.TestCheckResourceAttr("coderd_organization.test", "display_name", "Example Organization"), + resource.TestCheckResourceAttr("coderd_organization.test", "icon", "/icon/coder.svg"), + resource.TestCheckResourceAttr("coderd_organization.test", "members.#", "1"), + resource.TestCheckResourceAttr("coderd_organization.test", "members.0", user1.ID.String()), + ), + }, + // Import + { + Config: cfg1.String(t), + ResourceName: "coderd_organization.test", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"members"}, + }, + // Update and Read + { + Config: cfg2.String(t), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("coderd_organization.test", "name", "example-org-new"), + resource.TestCheckResourceAttr("coderd_organization.test", "display_name", "Example Organization New"), + resource.TestCheckResourceAttr("coderd_organization.test", "members.#", "1"), + resource.TestCheckResourceAttr("coderd_organization.test", "members.0", user2.ID.String()), + ), + }, + // Unmanaged members + { + Config: cfg3.String(t), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckNoResourceAttr("coderd_organization.test", "members"), + ), + }, + }, + }) + }) + + t.Run("CreateUnmanagedMembersOk", func(t *testing.T) { + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: cfg3.String(t), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckNoResourceAttr("coderd_organization.test", "members"), + ), + }, + }, + }) + }) +} + +type testAccOrganizationResourceConfig struct { + URL string + Token string + + Name *string + DisplayName *string + Description *string + Icon *string + Members *[]string +} + +func (c testAccOrganizationResourceConfig) String(t *testing.T) string { + t.Helper() + tpl := ` +provider coderd { + url = "{{.URL}}" + token = "{{.Token}}" +} + +resource "coderd_organization" "test" { + name = {{orNull .Name}} + display_name = {{orNull .DisplayName}} + description = {{orNull .Description}} + icon = {{orNull .Icon}} + members = {{orNull .Members}} +} +` + funcMap := template.FuncMap{ + "orNull": PrintOrNull, + } + + buf := strings.Builder{} + tmpl, err := template.New("organizationResource").Funcs(funcMap).Parse(tpl) + require.NoError(t, err) + + err = tmpl.Execute(&buf, c) + require.NoError(t, err) + return buf.String() +} From 33979849794cd2c0172f1be28a59a50ce720ee0f Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Fri, 8 Nov 2024 18:39:54 +0000 Subject: [PATCH 05/25] ooooh, I get it, that was correct :^) --- internal/provider/organization_resource.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/provider/organization_resource.go b/internal/provider/organization_resource.go index 14b7a8f..5c02585 100644 --- a/internal/provider/organization_resource.go +++ b/internal/provider/organization_resource.go @@ -206,7 +206,7 @@ func (r *OrganizationResource) Create(ctx context.Context, req resource.CreateRe resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get current user, got error: %s", err)) return } - if slice.Contains(members, UUIDValue(me.ID)) { + if !slice.Contains(members, UUIDValue(me.ID)) { err = r.Client.DeleteOrganizationMember(ctx, org.ID, codersdk.Me) if err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete self from new organization: %s", err)) From 75c08589630820b395ebe84d745c18c14db3f28f Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Fri, 8 Nov 2024 19:44:52 +0000 Subject: [PATCH 06/25] hmm --- internal/provider/organization_resource_test.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/internal/provider/organization_resource_test.go b/internal/provider/organization_resource_test.go index df9bcb3..aa65b8e 100644 --- a/internal/provider/organization_resource_test.go +++ b/internal/provider/organization_resource_test.go @@ -73,8 +73,6 @@ func TestAccOrganizationResource(t *testing.T) { resource.TestCheckResourceAttr("coderd_organization.test", "name", "example-org"), resource.TestCheckResourceAttr("coderd_organization.test", "display_name", "Example Organization"), resource.TestCheckResourceAttr("coderd_organization.test", "icon", "/icon/coder.svg"), - resource.TestCheckResourceAttr("coderd_organization.test", "members.#", "1"), - resource.TestCheckResourceAttr("coderd_organization.test", "members.0", user1.ID.String()), ), }, // Import @@ -91,8 +89,6 @@ func TestAccOrganizationResource(t *testing.T) { Check: resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttr("coderd_organization.test", "name", "example-org-new"), resource.TestCheckResourceAttr("coderd_organization.test", "display_name", "Example Organization New"), - resource.TestCheckResourceAttr("coderd_organization.test", "members.#", "1"), - resource.TestCheckResourceAttr("coderd_organization.test", "members.0", user2.ID.String()), ), }, // Unmanaged members From cc2bb2eecbc1b36c84015c5a0814d5b712e6816c Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Fri, 8 Nov 2024 20:10:42 +0000 Subject: [PATCH 07/25] lets do members differently actually --- internal/provider/organization_resource.go | 97 ------------------- .../provider/organization_resource_test.go | 34 +------ 2 files changed, 5 insertions(+), 126 deletions(-) diff --git a/internal/provider/organization_resource.go b/internal/provider/organization_resource.go index 5c02585..f518f50 100644 --- a/internal/provider/organization_resource.go +++ b/internal/provider/organization_resource.go @@ -4,11 +4,8 @@ import ( "context" "fmt" - "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" "github.com/coder/terraform-provider-coderd/internal/codersdkvalidator" - "github.com/google/uuid" - "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" @@ -36,7 +33,6 @@ type OrganizationResourceModel struct { DisplayName types.String `tfsdk:"display_name"` Description types.String `tfsdk:"description"` Icon types.String `tfsdk:"icon"` - Members types.Set `tfsdk:"members"` } func NewOrganizationResource() resource.Resource { @@ -85,11 +81,6 @@ func (r *OrganizationResource) Schema(ctx context.Context, req resource.SchemaRe Computed: true, Default: stringdefault.StaticString(""), }, - "members": schema.SetAttribute{ - MarkdownDescription: "Members of the organization, by ID. If null, members will not be added or removed by Terraform.", - ElementType: UUIDType, - Optional: true, - }, }, } } @@ -136,18 +127,6 @@ func (r *OrganizationResource) Read(ctx context.Context, req resource.ReadReques data.DisplayName = types.StringValue(org.DisplayName) data.Description = types.StringValue(org.Description) data.Icon = types.StringValue(org.Icon) - if !data.Members.IsNull() { - members, err := r.Client.OrganizationMembers(ctx, orgID) - 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, UUIDValue(member.UserID)) - } - data.Members = types.SetValueMust(UUIDType, memberIDs) - } // Save updated data into Terraform state resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) @@ -181,42 +160,6 @@ func (r *OrganizationResource) Create(ctx context.Context, req resource.CreateRe // default it. data.DisplayName = types.StringValue(org.DisplayName) - // Only configure members if they're specified - if !data.Members.IsNull() { - tflog.Trace(ctx, "setting organization members") - var members []UUID - resp.Diagnostics.Append(data.Members.ElementsAs(ctx, &members, false)...) - if resp.Diagnostics.HasError() { - return - } - - for _, memberID := range members { - _, err = r.Client.PostOrganizationMember(ctx, org.ID, memberID.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to add member %s to organization %s, got error: %s", memberID, org.ID, err)) - return - } - } - - // Coder adds the user who creates the organization by default, but we may - // actually be connected as a user who isn't in the list of members. If so - // we should remove them! - me, err := r.Client.User(ctx, codersdk.Me) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get current user, got error: %s", err)) - return - } - if !slice.Contains(members, UUIDValue(me.ID)) { - err = r.Client.DeleteOrganizationMember(ctx, org.ID, codersdk.Me) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete self from new organization: %s", err)) - return - } - } - - tflog.Trace(ctx, "successfully set organization members") - } - // Save data into Terraform state resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } @@ -251,46 +194,6 @@ func (r *OrganizationResource) Update(ctx context.Context, req resource.UpdateRe } tflog.Trace(ctx, "successfully updated organization") - // If the organization membership is managed, update them. - if !data.Members.IsNull() { - orgMembers, err := r.Client.OrganizationMembers(ctx, orgID) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get organization members , got error: %s", err)) - return - } - currentMembers := make([]uuid.UUID, 0, len(orgMembers)) - for _, member := range orgMembers { - currentMembers = append(currentMembers, member.UserID) - } - - var plannedMembers []UUID - resp.Diagnostics.Append(data.Members.ElementsAs(ctx, &plannedMembers, false)...) - if resp.Diagnostics.HasError() { - return - } - - add, remove := memberDiff(currentMembers, plannedMembers) - tflog.Trace(ctx, "updating organization members", map[string]any{ - "new_members": add, - "removed_members": remove, - }) - for _, memberID := range add { - _, err := r.Client.PostOrganizationMember(ctx, orgID, memberID) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to add member %s to organization %s, got error: %s", memberID, orgID, err)) - return - } - } - for _, memberID := range remove { - err := r.Client.DeleteOrganizationMember(ctx, orgID, memberID) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to remove member %s from organization %s, got error: %s", memberID, orgID, err)) - return - } - } - tflog.Trace(ctx, "successfully updated organization members") - } - // Save updated data into Terraform state resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } diff --git a/internal/provider/organization_resource_test.go b/internal/provider/organization_resource_test.go index aa65b8e..fa1cda5 100644 --- a/internal/provider/organization_resource_test.go +++ b/internal/provider/organization_resource_test.go @@ -21,25 +21,7 @@ func TestAccOrganizationResource(t *testing.T) { ctx := context.Background() client := integration.StartCoder(ctx, t, "group_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], - }) + _, err := client.User(ctx, codersdk.Me) require.NoError(t, err) cfg1 := testAccOrganizationResourceConfig{ @@ -49,16 +31,13 @@ func TestAccOrganizationResource(t *testing.T) { DisplayName: ptr.Ref("Example Organization"), Description: ptr.Ref("This is an example organization"), Icon: ptr.Ref("/icon/coder.svg"), - Members: ptr.Ref([]string{user1.ID.String()}), } cfg2 := cfg1 cfg2.Name = ptr.Ref("example-org-new") cfg2.DisplayName = ptr.Ref("Example Organization New") - cfg2.Members = ptr.Ref([]string{user2.ID.String()}) cfg3 := cfg2 - cfg3.Members = nil t.Run("CreateImportUpdateReadOk", func(t *testing.T) { resource.Test(t, resource.TestCase{ @@ -77,11 +56,10 @@ func TestAccOrganizationResource(t *testing.T) { }, // Import { - Config: cfg1.String(t), - ResourceName: "coderd_organization.test", - ImportState: true, - ImportStateVerify: true, - ImportStateVerifyIgnore: []string{"members"}, + Config: cfg1.String(t), + ResourceName: "coderd_organization.test", + ImportState: true, + ImportStateVerify: true, }, // Update and Read { @@ -127,7 +105,6 @@ type testAccOrganizationResourceConfig struct { DisplayName *string Description *string Icon *string - Members *[]string } func (c testAccOrganizationResourceConfig) String(t *testing.T) string { @@ -143,7 +120,6 @@ resource "coderd_organization" "test" { display_name = {{orNull .DisplayName}} description = {{orNull .Description}} icon = {{orNull .Icon}} - members = {{orNull .Members}} } ` funcMap := template.FuncMap{ From d23168a97391884915834b330d0fc60405183a93 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Fri, 8 Nov 2024 20:13:03 +0000 Subject: [PATCH 08/25] gen --- Makefile | 4 ++++ docs/resources/organization.md | 1 - 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 54a7a12..b1f903b 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,12 @@ default: testacc fmt: + go fmt ./... terraform fmt -recursive +vet: + go vet ./... + gen: go generate ./... diff --git a/docs/resources/organization.md b/docs/resources/organization.md index e284875..edef201 100644 --- a/docs/resources/organization.md +++ b/docs/resources/organization.md @@ -24,7 +24,6 @@ An organization on the Coder deployment - `description` (String) - `display_name` (String) Display name of the organization. Defaults to name. - `icon` (String) -- `members` (Set of String) Members of the organization, by ID. If null, members will not be added or removed by Terraform. ### Read-Only From 28b395ae07b642f48c1361153ee7917edcc2cd53 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Fri, 8 Nov 2024 22:38:47 +0000 Subject: [PATCH 09/25] statecheck --- .../provider/organization_resource_test.go | 28 ++++++++----------- internal/provider/provider.go | 1 - 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/internal/provider/organization_resource_test.go b/internal/provider/organization_resource_test.go index fa1cda5..174a8ca 100644 --- a/internal/provider/organization_resource_test.go +++ b/internal/provider/organization_resource_test.go @@ -11,6 +11,9 @@ import ( "github.com/coder/coder/v2/codersdk" "github.com/coder/terraform-provider-coderd/integration" "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" "github.com/stretchr/testify/require" ) @@ -48,11 +51,11 @@ func TestAccOrganizationResource(t *testing.T) { // Create and Read { Config: cfg1.String(t), - Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttr("coderd_organization.test", "name", "example-org"), - resource.TestCheckResourceAttr("coderd_organization.test", "display_name", "Example Organization"), - resource.TestCheckResourceAttr("coderd_organization.test", "icon", "/icon/coder.svg"), - ), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("name"), knownvalue.StringExact("example-org")), + statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("display_name"), knownvalue.StringExact("Example Organization")), + statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("icon"), knownvalue.StringExact("/icon/coder.svg")), + }, }, // Import { @@ -64,17 +67,10 @@ func TestAccOrganizationResource(t *testing.T) { // Update and Read { Config: cfg2.String(t), - Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttr("coderd_organization.test", "name", "example-org-new"), - resource.TestCheckResourceAttr("coderd_organization.test", "display_name", "Example Organization New"), - ), - }, - // Unmanaged members - { - Config: cfg3.String(t), - Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckNoResourceAttr("coderd_organization.test", "members"), - ), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("name"), knownvalue.StringExact("example-org-new")), + statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("display_name"), knownvalue.StringExact("Example Organization New")), + }, }, }, }) diff --git a/internal/provider/provider.go b/internal/provider/provider.go index cc79997..7b7d165 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -78,7 +78,6 @@ This provider is only compatible with Coder version [2.10.1](https://github.com/ func (p *CoderdProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) { var data CoderdProviderModel - resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) if resp.Diagnostics.HasError() { From f2d3e3ccb057f3a0ad77baad03279744ef3e1602 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Tue, 12 Nov 2024 21:19:55 +0000 Subject: [PATCH 10/25] feedback --- internal/provider/organization_resource.go | 7 ++++--- internal/provider/organization_resource_test.go | 16 ---------------- 2 files changed, 4 insertions(+), 19 deletions(-) diff --git a/internal/provider/organization_resource.go b/internal/provider/organization_resource.go index f518f50..05bcc9a 100644 --- a/internal/provider/organization_resource.go +++ b/internal/provider/organization_resource.go @@ -57,7 +57,7 @@ func (r *OrganizationResource) Schema(ctx context.Context, req resource.SchemaRe }, }, "name": schema.StringAttribute{ - MarkdownDescription: "Username of the organization.", + MarkdownDescription: "Name of the organization.", Required: true, Validators: []validator.String{ codersdkvalidator.Name(), @@ -67,6 +67,7 @@ func (r *OrganizationResource) Schema(ctx context.Context, req resource.SchemaRe MarkdownDescription: "Display name of the organization. Defaults to name.", Computed: true, Optional: true, + Default: stringdefault.StaticString(""), Validators: []validator.String{ codersdkvalidator.DisplayName(), }, @@ -224,6 +225,6 @@ func (r *OrganizationResource) Delete(ctx context.Context, req resource.DeleteRe func (r *OrganizationResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { // Terraform will eventually `Read` in the rest of the fields after we have - // set the `id` attribute. - resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) + // set the `name` attribute. + resource.ImportStatePassthroughID(ctx, path.Root("name"), req, resp) } diff --git a/internal/provider/organization_resource_test.go b/internal/provider/organization_resource_test.go index 174a8ca..4dce520 100644 --- a/internal/provider/organization_resource_test.go +++ b/internal/provider/organization_resource_test.go @@ -75,22 +75,6 @@ func TestAccOrganizationResource(t *testing.T) { }, }) }) - - t.Run("CreateUnmanagedMembersOk", func(t *testing.T) { - resource.Test(t, resource.TestCase{ - IsUnitTest: true, - PreCheck: func() { testAccPreCheck(t) }, - ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, - Steps: []resource.TestStep{ - { - Config: cfg3.String(t), - Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckNoResourceAttr("coderd_organization.test", "members"), - ), - }, - }, - }) - }) } type testAccOrganizationResourceConfig struct { From 236c11e01d239ea875c8b1af85253550de64c4c7 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Tue, 12 Nov 2024 21:30:33 +0000 Subject: [PATCH 11/25] hiyo --- docs/resources/organization.md | 2 +- examples/resources/coderd_organization/import.sh | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 examples/resources/coderd_organization/import.sh diff --git a/docs/resources/organization.md b/docs/resources/organization.md index edef201..0b4b817 100644 --- a/docs/resources/organization.md +++ b/docs/resources/organization.md @@ -17,7 +17,7 @@ An organization on the Coder deployment ### Required -- `name` (String) Username of the organization. +- `name` (String) Name of the organization. ### Optional diff --git a/examples/resources/coderd_organization/import.sh b/examples/resources/coderd_organization/import.sh new file mode 100644 index 0000000..cd93ce2 --- /dev/null +++ b/examples/resources/coderd_organization/import.sh @@ -0,0 +1 @@ +terraform import coderd_organization.our_org our_org From 16d10e7e9fe052329ea5b0bc23b108a6448e5c6c Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Tue, 12 Nov 2024 21:39:46 +0000 Subject: [PATCH 12/25] :^) --- docs/resources/organization.md | 9 +++++++++ examples/resources/coderd_organization/import.sh | 1 + internal/provider/organization_resource.go | 4 ++-- internal/provider/organization_resource_test.go | 2 -- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/resources/organization.md b/docs/resources/organization.md index 0b4b817..a5e2402 100644 --- a/docs/resources/organization.md +++ b/docs/resources/organization.md @@ -28,3 +28,12 @@ An organization on the Coder deployment ### Read-Only - `id` (String) Organization ID + +## Import + +Import is supported using the following syntax: + +```shell +# Organizations can be imported by their name +terraform import coderd_organization.our_org our_org +``` diff --git a/examples/resources/coderd_organization/import.sh b/examples/resources/coderd_organization/import.sh index cd93ce2..882dce6 100644 --- a/examples/resources/coderd_organization/import.sh +++ b/examples/resources/coderd_organization/import.sh @@ -1 +1,2 @@ +# Organizations can be imported by their name terraform import coderd_organization.our_org our_org diff --git a/internal/provider/organization_resource.go b/internal/provider/organization_resource.go index 05bcc9a..9b5fc49 100644 --- a/internal/provider/organization_resource.go +++ b/internal/provider/organization_resource.go @@ -114,8 +114,8 @@ func (r *OrganizationResource) Read(ctx context.Context, req resource.ReadReques return } - orgID := data.ID.ValueUUID() - org, err := r.Client.Organization(ctx, orgID) + orgName := data.Name.ValueString() + org, err := r.Client.OrganizationByName(ctx, orgName) if err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get organization by ID, got error: %s", err)) return diff --git a/internal/provider/organization_resource_test.go b/internal/provider/organization_resource_test.go index 4dce520..6003103 100644 --- a/internal/provider/organization_resource_test.go +++ b/internal/provider/organization_resource_test.go @@ -40,8 +40,6 @@ func TestAccOrganizationResource(t *testing.T) { cfg2.Name = ptr.Ref("example-org-new") cfg2.DisplayName = ptr.Ref("Example Organization New") - cfg3 := cfg2 - t.Run("CreateImportUpdateReadOk", func(t *testing.T) { resource.Test(t, resource.TestCase{ IsUnitTest: true, From f3ff5fb699a85409d837f28b3d4890515b180984 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Tue, 12 Nov 2024 21:52:01 +0000 Subject: [PATCH 13/25] how about --- internal/provider/organization_resource.go | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/internal/provider/organization_resource.go b/internal/provider/organization_resource.go index 9b5fc49..f3dd8c9 100644 --- a/internal/provider/organization_resource.go +++ b/internal/provider/organization_resource.go @@ -114,11 +114,23 @@ func (r *OrganizationResource) Read(ctx context.Context, req resource.ReadReques return } - orgName := data.Name.ValueString() - org, err := r.Client.OrganizationByName(ctx, orgName) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get organization by ID, got error: %s", err)) - return + var org codersdk.Organization + var err error + if data.ID.IsNull() { + orgName := data.Name.ValueString() + org, err = r.Client.OrganizationByName(ctx, orgName) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get organization by name, got error: %s", err)) + return + } + data.ID = UUIDValue(org.ID) + } else { + orgID := data.ID.ValueUUID() + org, err = r.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 + } } // We've fetched the organization ID from state, and the latest values for From a2db0d69a06d79ddae8f2e2c101071d4501b3605 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Tue, 12 Nov 2024 23:16:37 +0000 Subject: [PATCH 14/25] this is probably bad :) --- internal/provider/organization_resource.go | 69 ++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/internal/provider/organization_resource.go b/internal/provider/organization_resource.go index f3dd8c9..df08413 100644 --- a/internal/provider/organization_resource.go +++ b/internal/provider/organization_resource.go @@ -3,9 +3,11 @@ package provider import ( "context" "fmt" + "regexp" "github.com/coder/coder/v2/codersdk" "github.com/coder/terraform-provider-coderd/internal/codersdkvalidator" + "github.com/google/uuid" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" @@ -33,6 +35,9 @@ type OrganizationResourceModel struct { DisplayName types.String `tfsdk:"display_name"` Description types.String `tfsdk:"description"` Icon types.String `tfsdk:"icon"` + + GroupSync types.Object `tfsdk:"group_sync"` + RoleSync types.Object `tfsdk:"role_sync"` } func NewOrganizationResource() resource.Resource { @@ -82,6 +87,13 @@ func (r *OrganizationResource) Schema(ctx context.Context, req resource.SchemaRe Computed: true, Default: stringdefault.StaticString(""), }, + + "group_sync": schema.ObjectAttribute{ + Optional: true, + }, + "role_sync": schema.ObjectAttribute{ + Optional: true, + }, }, } } @@ -207,6 +219,14 @@ func (r *OrganizationResource) Update(ctx context.Context, req resource.UpdateRe } tflog.Trace(ctx, "successfully updated organization") + if data.GroupSync.IsNull() { + err = r.patchGroupSync(ctx, orgID, data.GroupSync) + if err != nil { + resp.Diagnostics.AddError("Group Sync Update error", "uh oh john") + return + } + } + // Save updated data into Terraform state resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } @@ -240,3 +260,52 @@ func (r *OrganizationResource) ImportState(ctx context.Context, req resource.Imp // set the `name` attribute. resource.ImportStatePassthroughID(ctx, path.Root("name"), req, resp) } + +func (r *OrganizationResource) patchGroupSync( + ctx context.Context, + orgID uuid.UUID, + groupSyncAttr types.Object, +) error { + var settings codersdk.GroupSyncSettings + + field, ok := groupSyncAttr.Attributes()["field"].(types.String) + if !ok { + return fmt.Errorf("oh jeez") + } + settings.Field = field.ValueString() + + mappingMap, ok := groupSyncAttr.Attributes()["mapping"].(types.Map) + if !ok { + return fmt.Errorf("oh jeez") + } + var mapping map[string][]uuid.UUID + diags := mappingMap.ElementsAs(ctx, mapping, false) + if diags.HasError() { + return fmt.Errorf("oh jeez") + } + settings.Mapping = mapping + + regexFilterStr, ok := groupSyncAttr.Attributes()["regex_filter"].(types.String) + if !ok { + return fmt.Errorf("oh jeez") + } + regexFilter, err := regexp.Compile(regexFilterStr.ValueString()) + if err != nil { + return err + } + settings.RegexFilter = regexFilter + + legacyMappingMap, ok := groupSyncAttr.Attributes()["legacy_group_name_mapping"].(types.Map) + if !ok { + return fmt.Errorf("oh jeez") + } + var legacyMapping map[string]string + diags = legacyMappingMap.ElementsAs(ctx, legacyMapping, false) + if diags.HasError() { + return fmt.Errorf("oh jeez") + } + settings.LegacyNameMapping = legacyMapping + + _, err = r.Client.PatchGroupIDPSyncSettings(ctx, orgID.String(), settings) + return err +} From 93f476bf4c6bce60a6c9414adf96f703040c3438 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 13 Nov 2024 16:50:07 +0000 Subject: [PATCH 15/25] this is probably also bad --- internal/customtypes/group_sync.go | 145 ++++++++++++++++++ internal/{provider => customtypes}/uuid.go | 0 .../uuid_internal_test.go | 0 3 files changed, 145 insertions(+) create mode 100644 internal/customtypes/group_sync.go rename internal/{provider => customtypes}/uuid.go (100%) rename internal/{provider => customtypes}/uuid_internal_test.go (100%) diff --git a/internal/customtypes/group_sync.go b/internal/customtypes/group_sync.go new file mode 100644 index 0000000..da06889 --- /dev/null +++ b/internal/customtypes/group_sync.go @@ -0,0 +1,145 @@ +package provider + +import ( + "context" + "fmt" + + "github.com/coder/coder/v2/codersdk" + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/attr/xattr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +type groupSyncSettingsType struct { + basetypes.MapType +} + +var _ basetypes.MapTypable = GroupSyncSettingsType + +var GroupSyncSettingsType = groupSyncSettingsType{} + +func (t groupSyncSettingsType) ValueType(ctx context.Context) attr.Value { + return GroupSyncSettings{} +} + +// Equal implements basetypes.StringTypable. +func (t groupSyncSettingsType) Equal(o attr.Type) bool { + if o, ok := o.(groupSyncSettingsType); ok { + return t.MapType.Equal(o.MapType) + } + return false +} + +// ValueFromString implements basetypes.StringTypable. +func (t groupSyncSettingsType) ValueFromString(ctx context.Context, in basetypes.StringValue) (basetypes.StringValuable, diag.Diagnostics) { + var diags diag.Diagnostics + + if in.IsNull() { + return NewUUIDNull(), diags + } + if in.IsUnknown() { + return NewUUIDUnknown(), diags + } + + value, err := uuid.Parse(in.ValueString()) + if err != nil { + // The framework doesn't want us to return validation errors here + // for some reason. They get caught by `ValidateAttribute` instead, + // and this function isn't called directly by our provider - UUIDValue + // takes a valid GroupSyncSettings instead of a string. + return NewUUIDUnknown(), diags + } + + return UUIDValue(value), diags +} + +// ValueFromTerraform implements basetypes.StringTypable. +func (t groupSyncSettingsType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (attr.Value, error) { + attrValue, err := t.StringType.ValueFromTerraform(ctx, in) + + if err != nil { + return nil, err + } + + stringValue, ok := attrValue.(basetypes.StringValue) + + if !ok { + return nil, fmt.Errorf("unexpected type %T, expected basetypes.StringValue", attrValue) + } + + stringValuable, diags := t.ValueFromString(ctx, stringValue) + + if diags.HasError() { + return nil, fmt.Errorf("unexpected error converting StringValue to StringValuable: %v", diags) + } + + return stringValuable, nil +} + +type GroupSyncSettings struct { + // The framework requires custom types extend a primitive or object. + basetypes.MapValue + value codersdk.GroupSyncSettings +} + +var ( + _ basetypes.MapValuable = GroupSyncSettings{} + _ xattr.ValidateableAttribute = GroupSyncSettings{} +) + +func NewGroupSyncSettingsNull() GroupSyncSettings { + return GroupSyncSettings{ + MapValue: basetypes.NewMapNull(), + } +} + +func NewGroupSyncSettingsUnknown() GroupSyncSettings { + return GroupSyncSettings{ + MapValue: basetypes.NewMapUnknown(), + } +} + +func GroupSyncSettingsValue(value uuid.UUID) UUID { + return UUID{ + MapValue: basetypes.NewStringValue(value.String()), + value: value, + } +} + +// Equal implements basetypes.StringValuable. +func (v GroupSyncSettings) Equal(o attr.Value) bool { + if o, ok := o.(GroupSyncSettings); ok { + return v.StringValue.Equal(o.StringValue) + } + return false +} + +// Type implements basetypes.StringValuable. +func (v GroupSyncSettings) Type(context.Context) attr.Type { + return GroupSyncSettingsType +} + +// ValueUUID returns the GroupSyncSettings value. If the value is null or unknown, returns the Nil GroupSyncSettings. +func (v GroupSyncSettings) ValueUUID() uuid.GroupSyncSettings { + return v.value +} + +// ValidateAttribute implements xattr.ValidateableAttribute. +func (v GroupSyncSettings) ValidateAttribute(ctx context.Context, req xattr.ValidateAttributeRequest, resp *xattr.ValidateAttributeResponse) { + if v.IsNull() || v.IsUnknown() { + return + } + + if _, err := uuid.Parse(v.ValueString()); err != nil { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid GroupSyncSettings", + "The provided value cannot be parsed as a GroupSyncSettings\n\n"+ + "Path: "+req.Path.String()+"\n"+ + "Error: "+err.Error(), + ) + } +} diff --git a/internal/provider/uuid.go b/internal/customtypes/uuid.go similarity index 100% rename from internal/provider/uuid.go rename to internal/customtypes/uuid.go diff --git a/internal/provider/uuid_internal_test.go b/internal/customtypes/uuid_internal_test.go similarity index 100% rename from internal/provider/uuid_internal_test.go rename to internal/customtypes/uuid_internal_test.go From 69ebed95626c92ec61a8e79bc8ec263b1fa41423 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Mon, 25 Nov 2024 21:07:05 +0000 Subject: [PATCH 16/25] get the attribute definitions out there --- internal/provider/organization_resource.go | 54 +++++++++++++++++-- internal/{customtypes => provider}/uuid.go | 0 .../uuid_internal_test.go | 0 3 files changed, 50 insertions(+), 4 deletions(-) rename internal/{customtypes => provider}/uuid.go (100%) rename internal/{customtypes => provider}/uuid_internal_test.go (100%) diff --git a/internal/provider/organization_resource.go b/internal/provider/organization_resource.go index 256f915..853b9a3 100644 --- a/internal/provider/organization_resource.go +++ b/internal/provider/organization_resource.go @@ -8,6 +8,7 @@ import ( "github.com/coder/coder/v2/codersdk" "github.com/coder/terraform-provider-coderd/internal/codersdkvalidator" "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" @@ -87,12 +88,57 @@ func (r *OrganizationResource) Schema(ctx context.Context, req resource.SchemaRe Computed: true, Default: stringdefault.StaticString(""), }, + }, - "group_sync": schema.ObjectAttribute{ - Optional: true, + Blocks: map[string]schema.Block{ + "group_sync": schema.SingleNestedBlock{ + Attributes: map[string]schema.Attribute{ + "field": schema.StringAttribute{ + Required: true, + MarkdownDescription: "The claim field that specifies what groups " + + "a user should be in.", + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + }, + "regex": schema.StringAttribute{ + Required: true, + MarkdownDescription: "A regular expression that will be used to " + + "filter the groups returned by the OIDC provider. Any group " + + "not matched will be ignored.", + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + }, + "auto_create_missing": schema.BoolAttribute{ + Required: true, + MarkdownDescription: "Controls whether groups will be created if " + + "they are missing.", + }, + "mapping": schema.MapAttribute{ + ElementType: UUIDType, + Required: true, + MarkdownDescription: "A map from OIDC group name to Coder group ID.", + }, + }, }, - "role_sync": schema.ObjectAttribute{ - Optional: true, + "role_sync": schema.SingleNestedBlock{ + Attributes: map[string]schema.Attribute{ + "field": schema.StringAttribute{ + Required: true, + MarkdownDescription: "The claim field that specifies what " + + "organization roles a user should be given.", + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + }, + "mapping": schema.MapAttribute{ + ElementType: UUIDType, + Required: true, + MarkdownDescription: "A map from OIDC group name to Coder " + + "organization role.", + }, + }, }, }, } diff --git a/internal/customtypes/uuid.go b/internal/provider/uuid.go similarity index 100% rename from internal/customtypes/uuid.go rename to internal/provider/uuid.go diff --git a/internal/customtypes/uuid_internal_test.go b/internal/provider/uuid_internal_test.go similarity index 100% rename from internal/customtypes/uuid_internal_test.go rename to internal/provider/uuid_internal_test.go From 3bf173413b8563175f7c6323f19826563a731f5b Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Tue, 26 Nov 2024 00:53:37 +0000 Subject: [PATCH 17/25] idek what I'm doing anymore apparently --- internal/customtypes/group_sync.go | 145 ------------------ internal/provider/organization_resource.go | 14 +- .../provider/organization_resource_test.go | 43 ++++++ 3 files changed, 50 insertions(+), 152 deletions(-) delete mode 100644 internal/customtypes/group_sync.go diff --git a/internal/customtypes/group_sync.go b/internal/customtypes/group_sync.go deleted file mode 100644 index da06889..0000000 --- a/internal/customtypes/group_sync.go +++ /dev/null @@ -1,145 +0,0 @@ -package provider - -import ( - "context" - "fmt" - - "github.com/coder/coder/v2/codersdk" - "github.com/google/uuid" - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/attr/xattr" - "github.com/hashicorp/terraform-plugin-framework/diag" - "github.com/hashicorp/terraform-plugin-framework/types/basetypes" - "github.com/hashicorp/terraform-plugin-go/tftypes" -) - -type groupSyncSettingsType struct { - basetypes.MapType -} - -var _ basetypes.MapTypable = GroupSyncSettingsType - -var GroupSyncSettingsType = groupSyncSettingsType{} - -func (t groupSyncSettingsType) ValueType(ctx context.Context) attr.Value { - return GroupSyncSettings{} -} - -// Equal implements basetypes.StringTypable. -func (t groupSyncSettingsType) Equal(o attr.Type) bool { - if o, ok := o.(groupSyncSettingsType); ok { - return t.MapType.Equal(o.MapType) - } - return false -} - -// ValueFromString implements basetypes.StringTypable. -func (t groupSyncSettingsType) ValueFromString(ctx context.Context, in basetypes.StringValue) (basetypes.StringValuable, diag.Diagnostics) { - var diags diag.Diagnostics - - if in.IsNull() { - return NewUUIDNull(), diags - } - if in.IsUnknown() { - return NewUUIDUnknown(), diags - } - - value, err := uuid.Parse(in.ValueString()) - if err != nil { - // The framework doesn't want us to return validation errors here - // for some reason. They get caught by `ValidateAttribute` instead, - // and this function isn't called directly by our provider - UUIDValue - // takes a valid GroupSyncSettings instead of a string. - return NewUUIDUnknown(), diags - } - - return UUIDValue(value), diags -} - -// ValueFromTerraform implements basetypes.StringTypable. -func (t groupSyncSettingsType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (attr.Value, error) { - attrValue, err := t.StringType.ValueFromTerraform(ctx, in) - - if err != nil { - return nil, err - } - - stringValue, ok := attrValue.(basetypes.StringValue) - - if !ok { - return nil, fmt.Errorf("unexpected type %T, expected basetypes.StringValue", attrValue) - } - - stringValuable, diags := t.ValueFromString(ctx, stringValue) - - if diags.HasError() { - return nil, fmt.Errorf("unexpected error converting StringValue to StringValuable: %v", diags) - } - - return stringValuable, nil -} - -type GroupSyncSettings struct { - // The framework requires custom types extend a primitive or object. - basetypes.MapValue - value codersdk.GroupSyncSettings -} - -var ( - _ basetypes.MapValuable = GroupSyncSettings{} - _ xattr.ValidateableAttribute = GroupSyncSettings{} -) - -func NewGroupSyncSettingsNull() GroupSyncSettings { - return GroupSyncSettings{ - MapValue: basetypes.NewMapNull(), - } -} - -func NewGroupSyncSettingsUnknown() GroupSyncSettings { - return GroupSyncSettings{ - MapValue: basetypes.NewMapUnknown(), - } -} - -func GroupSyncSettingsValue(value uuid.UUID) UUID { - return UUID{ - MapValue: basetypes.NewStringValue(value.String()), - value: value, - } -} - -// Equal implements basetypes.StringValuable. -func (v GroupSyncSettings) Equal(o attr.Value) bool { - if o, ok := o.(GroupSyncSettings); ok { - return v.StringValue.Equal(o.StringValue) - } - return false -} - -// Type implements basetypes.StringValuable. -func (v GroupSyncSettings) Type(context.Context) attr.Type { - return GroupSyncSettingsType -} - -// ValueUUID returns the GroupSyncSettings value. If the value is null or unknown, returns the Nil GroupSyncSettings. -func (v GroupSyncSettings) ValueUUID() uuid.GroupSyncSettings { - return v.value -} - -// ValidateAttribute implements xattr.ValidateableAttribute. -func (v GroupSyncSettings) ValidateAttribute(ctx context.Context, req xattr.ValidateAttributeRequest, resp *xattr.ValidateAttributeResponse) { - if v.IsNull() || v.IsUnknown() { - return - } - - if _, err := uuid.Parse(v.ValueString()); err != nil { - resp.Diagnostics.AddAttributeError( - req.Path, - "Invalid GroupSyncSettings", - "The provided value cannot be parsed as a GroupSyncSettings\n\n"+ - "Path: "+req.Path.String()+"\n"+ - "Error: "+err.Error(), - ) - } -} diff --git a/internal/provider/organization_resource.go b/internal/provider/organization_resource.go index 853b9a3..3075f1c 100644 --- a/internal/provider/organization_resource.go +++ b/internal/provider/organization_resource.go @@ -94,7 +94,7 @@ func (r *OrganizationResource) Schema(ctx context.Context, req resource.SchemaRe "group_sync": schema.SingleNestedBlock{ Attributes: map[string]schema.Attribute{ "field": schema.StringAttribute{ - Required: true, + Optional: true, MarkdownDescription: "The claim field that specifies what groups " + "a user should be in.", Validators: []validator.String{ @@ -102,7 +102,7 @@ func (r *OrganizationResource) Schema(ctx context.Context, req resource.SchemaRe }, }, "regex": schema.StringAttribute{ - Required: true, + Optional: true, MarkdownDescription: "A regular expression that will be used to " + "filter the groups returned by the OIDC provider. Any group " + "not matched will be ignored.", @@ -111,13 +111,13 @@ func (r *OrganizationResource) Schema(ctx context.Context, req resource.SchemaRe }, }, "auto_create_missing": schema.BoolAttribute{ - Required: true, + Optional: true, MarkdownDescription: "Controls whether groups will be created if " + "they are missing.", }, "mapping": schema.MapAttribute{ ElementType: UUIDType, - Required: true, + Optional: true, MarkdownDescription: "A map from OIDC group name to Coder group ID.", }, }, @@ -125,7 +125,7 @@ func (r *OrganizationResource) Schema(ctx context.Context, req resource.SchemaRe "role_sync": schema.SingleNestedBlock{ Attributes: map[string]schema.Attribute{ "field": schema.StringAttribute{ - Required: true, + Optional: true, MarkdownDescription: "The claim field that specifies what " + "organization roles a user should be given.", Validators: []validator.String{ @@ -134,7 +134,7 @@ func (r *OrganizationResource) Schema(ctx context.Context, req resource.SchemaRe }, "mapping": schema.MapAttribute{ ElementType: UUIDType, - Required: true, + Optional: true, MarkdownDescription: "A map from OIDC group name to Coder " + "organization role.", }, @@ -285,7 +285,7 @@ func (r *OrganizationResource) Update(ctx context.Context, req resource.UpdateRe if data.GroupSync.IsNull() { err = r.patchGroupSync(ctx, orgID, data.GroupSync) if err != nil { - resp.Diagnostics.AddError("Group Sync Update error", "uh oh john") + resp.Diagnostics.AddError("Group Sync Update error", err.Error()) return } } diff --git a/internal/provider/organization_resource_test.go b/internal/provider/organization_resource_test.go index b633265..1028274 100644 --- a/internal/provider/organization_resource_test.go +++ b/internal/provider/organization_resource_test.go @@ -2,6 +2,7 @@ package provider import ( "context" + "fmt" "os" "strings" "testing" @@ -10,6 +11,7 @@ import ( "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/terraform-provider-coderd/integration" + "github.com/google/uuid" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/knownvalue" "github.com/hashicorp/terraform-plugin-testing/statecheck" @@ -40,6 +42,22 @@ func TestAccOrganizationResource(t *testing.T) { cfg2.Name = ptr.Ref("example-org-new") cfg2.DisplayName = ptr.Ref("Example Organization New") + cfg3 := cfg1 + cfg3.GroupSync = ptr.Ref(codersdk.GroupSyncSettings{ + Field: "wibble", + Mapping: map[string][]uuid.UUID{ + "wibble": {uuid.MustParse("6e57187f-6543-46ab-a62c-a10065dd4314")}, + }, + }) + cfg3.RoleSync = ptr.Ref(codersdk.RoleSyncSettings{ + Field: "wibble", + Mapping: map[string][]string{ + "wibble": {"wobble"}, + }, + }) + + fmt.Println(cfg3) + t.Run("CreateImportUpdateReadOk", func(t *testing.T) { resource.Test(t, resource.TestCase{ IsUnitTest: true, @@ -84,6 +102,9 @@ type testAccOrganizationResourceConfig struct { DisplayName *string Description *string Icon *string + + GroupSync *codersdk.GroupSyncSettings + RoleSync *codersdk.RoleSyncSettings } func (c testAccOrganizationResourceConfig) String(t *testing.T) string { @@ -99,6 +120,28 @@ resource "coderd_organization" "test" { display_name = {{orNull .DisplayName}} description = {{orNull .Description}} icon = {{orNull .Icon}} + + {{- if .GroupSync}} + group_sync { + field = "{{.GroupSync.Field}}" + mapping = { + {{- range $key, $value := .GroupSync.Mapping}} + {{$key}} = "{{$value}}" + {{- end}} + } + } + {{- end}} + + {{- if .RoleSync}} + role_sync { + field = "{{.RoleSync.Field}}" + mapping = { + {{- range $key, $value := .RoleSync.Mapping}} + {{$key}} = "{{$value}}" + {{- end}} + } + } + {{- end}} } ` funcMap := template.FuncMap{ From a1852627405f26c8ea46391fa147624a6687acdf Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Tue, 26 Nov 2024 23:57:48 +0000 Subject: [PATCH 18/25] so close... --- internal/provider/organization_resource.go | 159 +++++++++++++----- .../provider/organization_resource_test.go | 21 ++- 2 files changed, 132 insertions(+), 48 deletions(-) diff --git a/internal/provider/organization_resource.go b/internal/provider/organization_resource.go index 3075f1c..000767c 100644 --- a/internal/provider/organization_resource.go +++ b/internal/provider/organization_resource.go @@ -8,7 +8,10 @@ import ( "github.com/coder/coder/v2/codersdk" "github.com/coder/terraform-provider-coderd/internal/codersdkvalidator" "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" @@ -17,6 +20,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" "github.com/hashicorp/terraform-plugin-log/tflog" ) @@ -41,6 +45,18 @@ type OrganizationResourceModel struct { RoleSync types.Object `tfsdk:"role_sync"` } +type GroupSyncModel struct { + Field types.String `tfsdk:"field"` + RegexFilter types.String `tfsdk:"regex_filter"` + AutoCreateMissing types.Bool `tfsdk:"auto_create_missing"` + Mapping types.Map `tfsdk:"mapping"` +} + +type RoleSyncModel struct { + Field types.String `tfsdk:"field"` + Mapping types.Map `tfsdk:"mapping"` +} + func NewOrganizationResource() resource.Resource { return &OrganizationResource{} } @@ -101,7 +117,7 @@ func (r *OrganizationResource) Schema(ctx context.Context, req resource.SchemaRe stringvalidator.LengthAtLeast(1), }, }, - "regex": schema.StringAttribute{ + "regex_filter": schema.StringAttribute{ Optional: true, MarkdownDescription: "A regular expression that will be used to " + "filter the groups returned by the OIDC provider. Any group " + @@ -116,9 +132,12 @@ func (r *OrganizationResource) Schema(ctx context.Context, req resource.SchemaRe "they are missing.", }, "mapping": schema.MapAttribute{ - ElementType: UUIDType, + ElementType: types.ListType{ElemType: UUIDType}, Optional: true, MarkdownDescription: "A map from OIDC group name to Coder group ID.", + Validators: []validator.Map{ + mapvalidator.ValueListsAre(listvalidator.ValueStringsAre(stringvalidator.Any())), + }, }, }, }, @@ -133,10 +152,13 @@ func (r *OrganizationResource) Schema(ctx context.Context, req resource.SchemaRe }, }, "mapping": schema.MapAttribute{ - ElementType: UUIDType, + ElementType: types.ListType{ElemType: UUIDType}, Optional: true, MarkdownDescription: "A map from OIDC group name to Coder " + "organization role.", + Validators: []validator.Map{ + mapvalidator.ValueListsAre(listvalidator.ValueStringsAre(stringvalidator.Any())), + }, }, }, }, @@ -191,6 +213,26 @@ func (r *OrganizationResource) Read(ctx context.Context, req resource.ReadReques } } + if !data.GroupSync.IsNull() { + _, err := r.Client.GroupIDPSyncSettings(ctx, data.ID.ValueUUID().String()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("unable to get organization group sync settings, got error: %s", err)) + return + } + + // data.GroupSync = ??? + } + + if !data.RoleSync.IsNull() { + _, err := r.Client.RoleIDPSyncSettings(ctx, data.ID.ValueUUID().String()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("unable to get organization role sync settings, got error: %s", err)) + return + } + + // data.RoleSync = ??? + } + // We've fetched the organization ID from state, and the latest values for // everything else from the backend. Ensure that any mutable data is synced // with the backend. @@ -241,6 +283,21 @@ func (r *OrganizationResource) Create(ctx context.Context, req resource.CreateRe // default it. data.DisplayName = types.StringValue(org.DisplayName) + // Now apply group and role sync settings, if specified + orgID := data.ID.ValueUUID() + tflog.Trace(ctx, "updating group sync", map[string]any{ + "orgID": orgID, + }) + if !data.GroupSync.IsNull() { + r.patchGroupSync(ctx, orgID, data.GroupSync) + } + tflog.Trace(ctx, "updating role sync", map[string]any{ + "orgID": orgID, + }) + if !data.RoleSync.IsNull() { + r.patchRoleSync(ctx, orgID, data.RoleSync) + } + // Save data into Terraform state resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } @@ -282,12 +339,17 @@ func (r *OrganizationResource) Update(ctx context.Context, req resource.UpdateRe "icon": org.Icon, }) - if data.GroupSync.IsNull() { - err = r.patchGroupSync(ctx, orgID, data.GroupSync) - if err != nil { - resp.Diagnostics.AddError("Group Sync Update error", err.Error()) - return - } + tflog.Trace(ctx, "updating group sync", map[string]any{ + "orgID": orgID, + }) + if !data.GroupSync.IsNull() { + r.patchGroupSync(ctx, orgID, data.GroupSync) + } + tflog.Trace(ctx, "updating role sync", map[string]any{ + "orgID": orgID, + }) + if !data.RoleSync.IsNull() { + r.patchRoleSync(ctx, orgID, data.RoleSync) } // Save updated data into Terraform state @@ -331,48 +393,65 @@ func (r *OrganizationResource) ImportState(ctx context.Context, req resource.Imp func (r *OrganizationResource) patchGroupSync( ctx context.Context, orgID uuid.UUID, - groupSyncAttr types.Object, -) error { - var settings codersdk.GroupSyncSettings + groupSyncObject types.Object, +) diag.Diagnostics { + var diags diag.Diagnostics - field, ok := groupSyncAttr.Attributes()["field"].(types.String) - if !ok { - return fmt.Errorf("oh jeez") + // Read values from Terraform + var groupSyncData GroupSyncModel + diags.Append(groupSyncObject.As(ctx, &groupSyncData, basetypes.ObjectAsOptions{})...) + if diags.HasError() { + return diags } - settings.Field = field.ValueString() - mappingMap, ok := groupSyncAttr.Attributes()["mapping"].(types.Map) - if !ok { - return fmt.Errorf("oh jeez") - } - var mapping map[string][]uuid.UUID - diags := mappingMap.ElementsAs(ctx, mapping, false) + // Convert that into the type used to send the PATCH to the backend + var groupSync codersdk.GroupSyncSettings + groupSync.Field = groupSyncData.Field.ValueString() + groupSync.RegexFilter = regexp.MustCompile(groupSyncData.RegexFilter.ValueString()) + groupSync.AutoCreateMissing = groupSyncData.AutoCreateMissing.ValueBool() + diags.Append(groupSyncData.Mapping.ElementsAs(ctx, &groupSync.Mapping, false)...) if diags.HasError() { - return fmt.Errorf("oh jeez") + return diags } - settings.Mapping = mapping - regexFilterStr, ok := groupSyncAttr.Attributes()["regex_filter"].(types.String) - if !ok { - return fmt.Errorf("oh jeez") - } - regexFilter, err := regexp.Compile(regexFilterStr.ValueString()) + // Perform the PATCH + _, err := r.Client.PatchGroupIDPSyncSettings(ctx, orgID.String(), groupSync) if err != nil { - return err + diags.AddError("Group Sync Update error", err.Error()) + return diags } - settings.RegexFilter = regexFilter - legacyMappingMap, ok := groupSyncAttr.Attributes()["legacy_group_name_mapping"].(types.Map) - if !ok { - return fmt.Errorf("oh jeez") + return diags +} + +func (r *OrganizationResource) patchRoleSync( + ctx context.Context, + orgID uuid.UUID, + roleSyncObject types.Object, +) diag.Diagnostics { + var diags diag.Diagnostics + + // Read values from Terraform + var roleSyncData RoleSyncModel + diags.Append(roleSyncObject.As(ctx, &roleSyncData, basetypes.ObjectAsOptions{})...) + if diags.HasError() { + return diags } - var legacyMapping map[string]string - diags = legacyMappingMap.ElementsAs(ctx, legacyMapping, false) + + // Convert that into the type used to send the PATCH to the backend + var roleSync codersdk.RoleSyncSettings + roleSync.Field = roleSyncData.Field.ValueString() + diags.Append(roleSyncData.Mapping.ElementsAs(ctx, &roleSync.Mapping, false)...) if diags.HasError() { - return fmt.Errorf("oh jeez") + return diags + } + + // Perform the PATCH + _, err := r.Client.PatchRoleIDPSyncSettings(ctx, orgID.String(), roleSync) + if err != nil { + diags.AddError("Role Sync Update error", err.Error()) + return diags } - settings.LegacyNameMapping = legacyMapping - _, err = r.Client.PatchGroupIDPSyncSettings(ctx, orgID.String(), settings) - return err + return diags } diff --git a/internal/provider/organization_resource_test.go b/internal/provider/organization_resource_test.go index 1028274..79530c2 100644 --- a/internal/provider/organization_resource_test.go +++ b/internal/provider/organization_resource_test.go @@ -2,7 +2,6 @@ package provider import ( "context" - "fmt" "os" "strings" "testing" @@ -42,7 +41,7 @@ func TestAccOrganizationResource(t *testing.T) { cfg2.Name = ptr.Ref("example-org-new") cfg2.DisplayName = ptr.Ref("Example Organization New") - cfg3 := cfg1 + cfg3 := cfg2 cfg3.GroupSync = ptr.Ref(codersdk.GroupSyncSettings{ Field: "wibble", Mapping: map[string][]uuid.UUID{ @@ -50,14 +49,12 @@ func TestAccOrganizationResource(t *testing.T) { }, }) cfg3.RoleSync = ptr.Ref(codersdk.RoleSyncSettings{ - Field: "wibble", + Field: "wobble", Mapping: map[string][]string{ - "wibble": {"wobble"}, + "wobble": {"wobbly"}, }, }) - fmt.Println(cfg3) - t.Run("CreateImportUpdateReadOk", func(t *testing.T) { resource.Test(t, resource.TestCase{ IsUnitTest: true, @@ -89,6 +86,14 @@ func TestAccOrganizationResource(t *testing.T) { statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("display_name"), knownvalue.StringExact("Example Organization New")), }, }, + // Add group and role sync + { + Config: cfg3.String(t), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("group_sync.field"), knownvalue.StringExact("wibble")), + statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("role_sync.field"), knownvalue.StringExact("wobble")), + }, + }, }, }) }) @@ -126,7 +131,7 @@ resource "coderd_organization" "test" { field = "{{.GroupSync.Field}}" mapping = { {{- range $key, $value := .GroupSync.Mapping}} - {{$key}} = "{{$value}}" + {{$key}} = {{printf "%q" $value}} {{- end}} } } @@ -137,7 +142,7 @@ resource "coderd_organization" "test" { field = "{{.RoleSync.Field}}" mapping = { {{- range $key, $value := .RoleSync.Mapping}} - {{$key}} = "{{$value}}" + {{$key}} = {{printf "%q" $value}} {{- end}} } } From 03bdf17ed68dd730fb571dd237e0f6eb5d1691f8 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 27 Nov 2024 18:12:57 +0000 Subject: [PATCH 19/25] pay attention --- internal/provider/organization_resource.go | 30 ++++++++++++---------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/internal/provider/organization_resource.go b/internal/provider/organization_resource.go index 000767c..aa1b61d 100644 --- a/internal/provider/organization_resource.go +++ b/internal/provider/organization_resource.go @@ -8,8 +8,6 @@ import ( "github.com/coder/coder/v2/codersdk" "github.com/coder/terraform-provider-coderd/internal/codersdkvalidator" "github.com/google/uuid" - "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" - "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/path" @@ -135,9 +133,6 @@ func (r *OrganizationResource) Schema(ctx context.Context, req resource.SchemaRe ElementType: types.ListType{ElemType: UUIDType}, Optional: true, MarkdownDescription: "A map from OIDC group name to Coder group ID.", - Validators: []validator.Map{ - mapvalidator.ValueListsAre(listvalidator.ValueStringsAre(stringvalidator.Any())), - }, }, }, }, @@ -152,13 +147,10 @@ func (r *OrganizationResource) Schema(ctx context.Context, req resource.SchemaRe }, }, "mapping": schema.MapAttribute{ - ElementType: types.ListType{ElemType: UUIDType}, + ElementType: types.ListType{ElemType: types.StringType}, Optional: true, MarkdownDescription: "A map from OIDC group name to Coder " + "organization role.", - Validators: []validator.Map{ - mapvalidator.ValueListsAre(listvalidator.ValueStringsAre(stringvalidator.Any())), - }, }, }, }, @@ -289,13 +281,19 @@ func (r *OrganizationResource) Create(ctx context.Context, req resource.CreateRe "orgID": orgID, }) if !data.GroupSync.IsNull() { - r.patchGroupSync(ctx, orgID, data.GroupSync) + resp.Diagnostics.Append(r.patchGroupSync(ctx, orgID, data.GroupSync)...) + if resp.Diagnostics.HasError() { + return + } } tflog.Trace(ctx, "updating role sync", map[string]any{ "orgID": orgID, }) if !data.RoleSync.IsNull() { - r.patchRoleSync(ctx, orgID, data.RoleSync) + resp.Diagnostics.Append(r.patchRoleSync(ctx, orgID, data.RoleSync)...) + if resp.Diagnostics.HasError() { + return + } } // Save data into Terraform state @@ -343,13 +341,19 @@ func (r *OrganizationResource) Update(ctx context.Context, req resource.UpdateRe "orgID": orgID, }) if !data.GroupSync.IsNull() { - r.patchGroupSync(ctx, orgID, data.GroupSync) + resp.Diagnostics.Append(r.patchGroupSync(ctx, orgID, data.GroupSync)...) + if resp.Diagnostics.HasError() { + return + } } tflog.Trace(ctx, "updating role sync", map[string]any{ "orgID": orgID, }) if !data.RoleSync.IsNull() { - r.patchRoleSync(ctx, orgID, data.RoleSync) + resp.Diagnostics.Append(r.patchRoleSync(ctx, orgID, data.RoleSync)...) + if resp.Diagnostics.HasError() { + return + } } // Save updated data into Terraform state From 753eaa9677bede675f4d912f1883ebfc216b68d4 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 27 Nov 2024 18:33:36 +0000 Subject: [PATCH 20/25] it WORKS! --- .../provider/organization_resource_test.go | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/internal/provider/organization_resource_test.go b/internal/provider/organization_resource_test.go index 79530c2..6b5fe93 100644 --- a/internal/provider/organization_resource_test.go +++ b/internal/provider/organization_resource_test.go @@ -10,7 +10,6 @@ import ( "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/terraform-provider-coderd/integration" - "github.com/google/uuid" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/knownvalue" "github.com/hashicorp/terraform-plugin-testing/statecheck" @@ -42,12 +41,12 @@ func TestAccOrganizationResource(t *testing.T) { cfg2.DisplayName = ptr.Ref("Example Organization New") cfg3 := cfg2 - cfg3.GroupSync = ptr.Ref(codersdk.GroupSyncSettings{ - Field: "wibble", - Mapping: map[string][]uuid.UUID{ - "wibble": {uuid.MustParse("6e57187f-6543-46ab-a62c-a10065dd4314")}, - }, - }) + // cfg3.GroupSync = ptr.Ref(codersdk.GroupSyncSettings{ + // Field: "wibble", + // Mapping: map[string][]uuid.UUID{ + // "wibble": {uuid.MustParse("6e57187f-6543-46ab-a62c-a10065dd4314")}, + // }, + // }) cfg3.RoleSync = ptr.Ref(codersdk.RoleSyncSettings{ Field: "wobble", Mapping: map[string][]string{ @@ -90,8 +89,10 @@ func TestAccOrganizationResource(t *testing.T) { { Config: cfg3.String(t), ConfigStateChecks: []statecheck.StateCheck{ - statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("group_sync.field"), knownvalue.StringExact("wibble")), - statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("role_sync.field"), knownvalue.StringExact("wobble")), + // statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("group_sync").AtMapKey("field"), knownvalue.StringExact("wibble")), + // statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("group_sync").AtMapKey("mapping").AtMapKey("wibble").AtSliceIndex(0), knownvalue.StringExact("6e57187f-6543-46ab-a62c-a10065dd4314")), + statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("role_sync").AtMapKey("field"), knownvalue.StringExact("wobble")), + statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("role_sync").AtMapKey("mapping").AtMapKey("wobble").AtSliceIndex(0), knownvalue.StringExact("wobbly")), }, }, }, From 85891d6b09ce9d046319516e7cd762c45734ddfd Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 27 Nov 2024 18:41:48 +0000 Subject: [PATCH 21/25] dogs --- docs/resources/organization.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/resources/organization.md b/docs/resources/organization.md index a5e2402..9556be4 100644 --- a/docs/resources/organization.md +++ b/docs/resources/organization.md @@ -23,12 +23,33 @@ An organization on the Coder deployment - `description` (String) - `display_name` (String) Display name of the organization. Defaults to name. +- `group_sync` (Block, Optional) (see [below for nested schema](#nestedblock--group_sync)) - `icon` (String) +- `role_sync` (Block, Optional) (see [below for nested schema](#nestedblock--role_sync)) ### Read-Only - `id` (String) Organization ID + +### Nested Schema for `group_sync` + +Optional: + +- `auto_create_missing` (Boolean) Controls whether groups will be created if they are missing. +- `field` (String) The claim field that specifies what groups a user should be in. +- `mapping` (Map of List of String) A map from OIDC group name to Coder group ID. +- `regex_filter` (String) A regular expression that will be used to filter the groups returned by the OIDC provider. Any group not matched will be ignored. + + + +### Nested Schema for `role_sync` + +Optional: + +- `field` (String) The claim field that specifies what organization roles a user should be given. +- `mapping` (Map of List of String) A map from OIDC group name to Coder organization role. + ## Import Import is supported using the following syntax: From bd73bb4a8a05204638abfd1f2ff61073ac8ee99b Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Mon, 2 Dec 2024 22:24:06 +0000 Subject: [PATCH 22/25] fix uuid handling --- internal/provider/organization_resource.go | 11 ++++++++++- internal/provider/organization_resource_test.go | 17 +++++++++-------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/internal/provider/organization_resource.go b/internal/provider/organization_resource.go index aa1b61d..ba6fa26 100644 --- a/internal/provider/organization_resource.go +++ b/internal/provider/organization_resource.go @@ -413,10 +413,19 @@ func (r *OrganizationResource) patchGroupSync( groupSync.Field = groupSyncData.Field.ValueString() groupSync.RegexFilter = regexp.MustCompile(groupSyncData.RegexFilter.ValueString()) groupSync.AutoCreateMissing = groupSyncData.AutoCreateMissing.ValueBool() - diags.Append(groupSyncData.Mapping.ElementsAs(ctx, &groupSync.Mapping, false)...) + groupSync.Mapping = make(map[string][]uuid.UUID) + // Terraform doesn't know how to turn one our `UUID` Terraform values into a + // `uuid.UUID`, so we have to do the unwrapping manually here. + var mapping map[string][]UUID + diags.Append(groupSyncData.Mapping.ElementsAs(ctx, &mapping, false)...) if diags.HasError() { return diags } + for key, ids := range mapping { + for _, id := range ids { + groupSync.Mapping[key] = append(groupSync.Mapping[key], id.ValueUUID()) + } + } // Perform the PATCH _, err := r.Client.PatchGroupIDPSyncSettings(ctx, orgID.String(), groupSync) diff --git a/internal/provider/organization_resource_test.go b/internal/provider/organization_resource_test.go index 6b5fe93..6ad3629 100644 --- a/internal/provider/organization_resource_test.go +++ b/internal/provider/organization_resource_test.go @@ -10,6 +10,7 @@ import ( "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/terraform-provider-coderd/integration" + "github.com/google/uuid" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/knownvalue" "github.com/hashicorp/terraform-plugin-testing/statecheck" @@ -41,12 +42,12 @@ func TestAccOrganizationResource(t *testing.T) { cfg2.DisplayName = ptr.Ref("Example Organization New") cfg3 := cfg2 - // cfg3.GroupSync = ptr.Ref(codersdk.GroupSyncSettings{ - // Field: "wibble", - // Mapping: map[string][]uuid.UUID{ - // "wibble": {uuid.MustParse("6e57187f-6543-46ab-a62c-a10065dd4314")}, - // }, - // }) + cfg3.GroupSync = ptr.Ref(codersdk.GroupSyncSettings{ + Field: "wibble", + Mapping: map[string][]uuid.UUID{ + "wibble": {uuid.MustParse("6e57187f-6543-46ab-a62c-a10065dd4314")}, + }, + }) cfg3.RoleSync = ptr.Ref(codersdk.RoleSyncSettings{ Field: "wobble", Mapping: map[string][]string{ @@ -89,8 +90,8 @@ func TestAccOrganizationResource(t *testing.T) { { Config: cfg3.String(t), ConfigStateChecks: []statecheck.StateCheck{ - // statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("group_sync").AtMapKey("field"), knownvalue.StringExact("wibble")), - // statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("group_sync").AtMapKey("mapping").AtMapKey("wibble").AtSliceIndex(0), knownvalue.StringExact("6e57187f-6543-46ab-a62c-a10065dd4314")), + statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("group_sync").AtMapKey("field"), knownvalue.StringExact("wibble")), + statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("group_sync").AtMapKey("mapping").AtMapKey("wibble").AtSliceIndex(0), knownvalue.StringExact("6e57187f-6543-46ab-a62c-a10065dd4314")), statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("role_sync").AtMapKey("field"), knownvalue.StringExact("wobble")), statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("role_sync").AtMapKey("mapping").AtMapKey("wobble").AtSliceIndex(0), knownvalue.StringExact("wobbly")), }, From 8f3e1b97e11040f488c64b4e66ce5bb2a18003c9 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Mon, 2 Dec 2024 22:51:25 +0000 Subject: [PATCH 23/25] fix container name --- internal/provider/organization_resource_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/provider/organization_resource_test.go b/internal/provider/organization_resource_test.go index 6ad3629..0a755c4 100644 --- a/internal/provider/organization_resource_test.go +++ b/internal/provider/organization_resource_test.go @@ -24,7 +24,7 @@ func TestAccOrganizationResource(t *testing.T) { } ctx := context.Background() - client := integration.StartCoder(ctx, t, "group_acc", true) + client := integration.StartCoder(ctx, t, "organization_acc", true) _, err := client.User(ctx, codersdk.Me) require.NoError(t, err) From d3f6e2ca7fd4b7b62c8c929142cbc8e849536817 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Tue, 3 Dec 2024 00:08:36 +0000 Subject: [PATCH 24/25] do the reads --- internal/provider/organization_resource.go | 88 +++++++++++++++++++++- 1 file changed, 84 insertions(+), 4 deletions(-) diff --git a/internal/provider/organization_resource.go b/internal/provider/organization_resource.go index ba6fa26..2062235 100644 --- a/internal/provider/organization_resource.go +++ b/internal/provider/organization_resource.go @@ -9,6 +9,7 @@ import ( "github.com/coder/terraform-provider-coderd/internal/codersdkvalidator" "github.com/google/uuid" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" @@ -50,11 +51,39 @@ type GroupSyncModel struct { Mapping types.Map `tfsdk:"mapping"` } +var groupSyncAttrTypes = map[string]attr.Type{ + "field": types.StringType, + "regex_filter": types.StringType, + "auto_create_missing": types.BoolType, + "mapping": types.MapType{ElemType: types.ListType{ElemType: UUIDType}}, +} + +func (m GroupSyncModel) ValueObject() types.Object { + return types.ObjectValueMust(groupSyncAttrTypes, map[string]attr.Value{ + "field": m.Field, + "regex_filter": m.RegexFilter, + "auto_create_missing": m.AutoCreateMissing, + "mapping": m.Mapping, + }) +} + type RoleSyncModel struct { Field types.String `tfsdk:"field"` Mapping types.Map `tfsdk:"mapping"` } +var roleSyncAttrTypes = map[string]attr.Type{ + "field": types.StringType, + "mapping": types.MapType{ElemType: types.ListType{ElemType: types.StringType}}, +} + +func (m RoleSyncModel) ValueObject() types.Object { + return types.ObjectValueMust(roleSyncAttrTypes, map[string]attr.Value{ + "field": m.Field, + "mapping": m.Mapping, + }) +} + func NewOrganizationResource() resource.Resource { return &OrganizationResource{} } @@ -206,23 +235,74 @@ func (r *OrganizationResource) Read(ctx context.Context, req resource.ReadReques } if !data.GroupSync.IsNull() { - _, err := r.Client.GroupIDPSyncSettings(ctx, data.ID.ValueUUID().String()) + groupSync, err := r.Client.GroupIDPSyncSettings(ctx, data.ID.ValueUUID().String()) if err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("unable to get organization group sync settings, got error: %s", err)) return } - // data.GroupSync = ??? + // Read values from Terraform + var groupSyncData GroupSyncModel + resp.Diagnostics.Append(data.GroupSync.As(ctx, &groupSyncData, basetypes.ObjectAsOptions{})...) + if resp.Diagnostics.HasError() { + return + } + + if !groupSyncData.Field.IsNull() { + groupSyncData.Field = types.StringValue(groupSync.Field) + } + if !groupSyncData.RegexFilter.IsNull() { + groupSyncData.RegexFilter = types.StringValue(groupSync.RegexFilter.String()) + } + if !groupSyncData.AutoCreateMissing.IsNull() { + groupSyncData.AutoCreateMissing = types.BoolValue(groupSync.AutoCreateMissing) + } + if !groupSyncData.Mapping.IsNull() { + elements := make(map[string][]string) + for key, ids := range groupSync.Mapping { + for _, id := range ids { + elements[key] = append(elements[key], id.String()) + } + } + + mapping, diags := types.MapValueFrom(ctx, types.ListType{ElemType: UUIDType}, elements) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + groupSyncData.Mapping = mapping + } + + data.GroupSync = groupSyncData.ValueObject() } if !data.RoleSync.IsNull() { - _, err := r.Client.RoleIDPSyncSettings(ctx, data.ID.ValueUUID().String()) + roleSync, err := r.Client.RoleIDPSyncSettings(ctx, data.ID.ValueUUID().String()) if err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("unable to get organization role sync settings, got error: %s", err)) return } - // data.RoleSync = ??? + // Read values from Terraform + var roleSyncData RoleSyncModel + resp.Diagnostics.Append(data.RoleSync.As(ctx, &roleSyncData, basetypes.ObjectAsOptions{})...) + if resp.Diagnostics.HasError() { + return + } + + if !roleSyncData.Field.IsNull() { + roleSyncData.Field = types.StringValue(roleSync.Field) + } + if !roleSyncData.Mapping.IsNull() { + mapping, diags := types.MapValueFrom(ctx, types.ListType{ElemType: types.StringType}, roleSync.Mapping) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + roleSyncData.Mapping = mapping + } + + data.RoleSync = roleSyncData.ValueObject() } // We've fetched the organization ID from state, and the latest values for From 33e09a9b35e38c0d8d27a0ef9586fffb29d5c9e9 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Tue, 3 Dec 2024 18:42:04 +0000 Subject: [PATCH 25/25] check regexp --- internal/codersdkvalidator/regex.go | 16 ++++++++++++++++ internal/provider/organization_resource.go | 1 + 2 files changed, 17 insertions(+) create mode 100644 internal/codersdkvalidator/regex.go diff --git a/internal/codersdkvalidator/regex.go b/internal/codersdkvalidator/regex.go new file mode 100644 index 0000000..0077616 --- /dev/null +++ b/internal/codersdkvalidator/regex.go @@ -0,0 +1,16 @@ +package codersdkvalidator + +import ( + "regexp" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func checkRegexp(it string) error { + _, err := regexp.Compile("") + return err +} + +func Regexp() validator.String { + return validatorFromFunc(checkRegexp, "value must be a valid regexp") +} diff --git a/internal/provider/organization_resource.go b/internal/provider/organization_resource.go index 2062235..397f92e 100644 --- a/internal/provider/organization_resource.go +++ b/internal/provider/organization_resource.go @@ -151,6 +151,7 @@ func (r *OrganizationResource) Schema(ctx context.Context, req resource.SchemaRe "not matched will be ignored.", Validators: []validator.String{ stringvalidator.LengthAtLeast(1), + codersdkvalidator.Regexp(), }, }, "auto_create_missing": schema.BoolAttribute{