Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add organization resource #131

Merged
merged 19 commits into from
Nov 14, 2024
Merged
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
default: testacc

fmt:
go fmt ./...
terraform fmt -recursive

vet:
go vet ./...

gen:
go generate ./...

Expand Down
39 changes: 39 additions & 0 deletions docs/resources/organization.md
Original file line number Diff line number Diff line change
@@ -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 generated by tfplugindocs -->
## 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
```
2 changes: 2 additions & 0 deletions examples/resources/coderd_organization/import.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Organizations can be imported by their name
terraform import coderd_organization.our_org our_org
262 changes: 262 additions & 0 deletions internal/provider/organization_resource.go
Original file line number Diff line number Diff line change
@@ -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
aslilac marked this conversation as resolved.
Show resolved Hide resolved
}

// 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(),
},
aslilac marked this conversation as resolved.
Show resolved Hide resolved
},
"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() {
Copy link
Member

@ethanndickson ethanndickson Nov 14, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your import test supplies a UUID into the name field, but Organization and OrganizationByName call the same endpoint, so I believe supporting imports by name will always allow you to import by ID? Hence why the import test is passing when it shouldn't. Setting the attributes at the bottom corrects the issue where the name is temporarily the UUID, so I don't think it's bug prone.

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)
}
Loading