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{ pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy