From 1ef2a69fe56969e75b1e560b8eb2babaecc61137 Mon Sep 17 00:00:00 2001 From: Kayla Washburn-Love Date: Thu, 14 Nov 2024 13:21:20 -0700 Subject: [PATCH] feat: add organization resource (#131) --- Makefile | 4 + docs/resources/organization.md | 39 +++ .../resources/coderd_organization/import.sh | 2 + internal/provider/organization_resource.go | 262 ++++++++++++++++++ .../provider/organization_resource_test.go | 115 ++++++++ internal/provider/provider.go | 2 +- internal/provider/util.go | 8 +- 7 files changed, 427 insertions(+), 5 deletions(-) create mode 100644 docs/resources/organization.md create mode 100644 examples/resources/coderd_organization/import.sh create mode 100644 internal/provider/organization_resource.go create mode 100644 internal/provider/organization_resource_test.go 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 new file mode 100644 index 0000000..a5e2402 --- /dev/null +++ b/docs/resources/organization.md @@ -0,0 +1,39 @@ +--- +# 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) Name of the organization. + +### Optional + +- `description` (String) +- `display_name` (String) Display name of the organization. Defaults to name. +- `icon` (String) + +### 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 new file mode 100644 index 0000000..882dce6 --- /dev/null +++ b/examples/resources/coderd_organization/import.sh @@ -0,0 +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 new file mode 100644 index 0000000..1575ce3 --- /dev/null +++ b/internal/provider/organization_resource.go @@ -0,0 +1,262 @@ +package provider + +import ( + "context" + "fmt" + + "github.com/coder/coder/v2/codersdk" + "github.com/coder/terraform-provider-coderd/internal/codersdkvalidator" + "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 { + *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"` +} + +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{ + "id": schema.StringAttribute{ + CustomType: UUIDType, + Computed: true, + MarkdownDescription: "Organization ID", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "name": schema.StringAttribute{ + MarkdownDescription: "Name of the organization.", + Required: true, + Validators: []validator.String{ + codersdkvalidator.Name(), + }, + }, + "display_name": schema.StringAttribute{ + MarkdownDescription: "Display name of the organization. Defaults to name.", + Computed: true, + Optional: true, + Default: stringdefault.StaticString(""), + Validators: []validator.String{ + codersdkvalidator.DisplayName(), + }, + }, + "description": schema.StringAttribute{ + Optional: true, + Computed: true, + Default: stringdefault.StaticString(""), + }, + "icon": schema.StringAttribute{ + Optional: true, + Computed: true, + Default: stringdefault.StaticString(""), + }, + }, + } +} + +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.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 + } + + 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 + // 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) + + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +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", map[string]any{ + "id": data.ID.ValueUUID(), + "name": data.Name.ValueString(), + "display_name": data.DisplayName.ValueString(), + "description": data.Description.ValueString(), + "icon": data.Icon.ValueString(), + }) + 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, + "name": org.Name, + "display_name": org.DisplayName, + "description": org.Description, + "icon": org.Icon, + }) + // 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) + + // 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.ValueString(), + "new_display_name": data.DisplayName.ValueString(), + "new_description": data.Description.ValueString(), + "new_icon": data.Icon.ValueString(), + }) + org, 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", map[string]any{ + "id": orgID, + "name": org.Name, + "display_name": org.DisplayName, + "description": org.Description, + "icon": org.Icon, + }) + + // 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, + "name": data.Name.ValueString(), + }) + 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", map[string]any{ + "id": orgID, + "name": data.Name.ValueString(), + }) + + // 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 `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 new file mode 100644 index 0000000..b633265 --- /dev/null +++ b/internal/provider/organization_resource_test.go @@ -0,0 +1,115 @@ +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/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" + "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) + _, err := client.User(ctx, codersdk.Me) + 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"), + } + + cfg2 := cfg1 + cfg2.Name = ptr.Ref("example-org-new") + cfg2.DisplayName = ptr.Ref("Example Organization New") + + 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), + 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 + { + Config: cfg1.String(t), + ResourceName: "coderd_organization.test", + ImportState: true, + ImportStateVerify: true, + ImportStateId: *cfg1.Name, + }, + // Update and Read + { + Config: cfg2.String(t), + 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")), + }, + }, + }, + }) + }) +} + +type testAccOrganizationResourceConfig struct { + URL string + Token string + + Name *string + DisplayName *string + Description *string + Icon *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}} +} +` + 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() +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index bfeea5e..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() { @@ -139,6 +138,7 @@ func (p *CoderdProvider) Resources(ctx context.Context) []func() resource.Resour NewTemplateResource, NewWorkspaceProxyResource, NewLicenseResource, + NewOrganizationResource, } } 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()) }