Skip to content

Commit

Permalink
Cloud: Add new cloud_org_member resource
Browse files Browse the repository at this point in the history
This resource alows Grafana Cloud users to manage the members of their org
It's implemented using the new Terraform plugin framework so some supporting work was also done
  • Loading branch information
julienduchesne committed Mar 21, 2024
1 parent aaa20a2 commit 86780e0
Show file tree
Hide file tree
Showing 12 changed files with 464 additions and 7 deletions.
38 changes: 38 additions & 0 deletions docs/resources/cloud_org_member.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
# generated by https://github.com/hashicorp/terraform-plugin-docs
page_title: "grafana_cloud_org_member Resource - terraform-provider-grafana"
subcategory: "Cloud"
description: |-
Manages the membership of a user in an organization.
---

# grafana_cloud_org_member (Resource)

Manages the membership of a user in an organization.



<!-- schema generated by tfplugindocs -->
## Schema

### Required

- `org` (String) The slug or ID of the organization.
- `role` (String) The role to assign to the user in the organization.
- `user` (String) Username or ID of the user to add to the org's members.

### Optional

- `receive_billing_emails` (Boolean) Whether the user should receive billing emails.

### Read-Only

- `id` (String) The ID of this resource.

## Import

Import is supported using the following syntax:

```shell
terraform import grafana_cloud_org_member.name "{{ orgSlugOrID }}:{{ usernameOrID }}"
```
1 change: 1 addition & 0 deletions examples/resources/grafana_cloud_org_member/import.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
terraform import grafana_cloud_org_member.name "{{ orgSlugOrID }}:{{ usernameOrID }}"
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ require (
github.com/hashicorp/hcl/v2 v2.20.0
github.com/hashicorp/terraform-plugin-docs v0.18.0
github.com/hashicorp/terraform-plugin-framework v1.6.1
github.com/hashicorp/terraform-plugin-framework-validators v0.12.0
github.com/hashicorp/terraform-plugin-go v0.22.1
github.com/hashicorp/terraform-plugin-mux v0.15.0
github.com/hashicorp/terraform-plugin-sdk/v2 v2.33.0
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ github.com/hashicorp/terraform-plugin-docs v0.18.0 h1:2bINhzXc+yDeAcafurshCrIjtd
github.com/hashicorp/terraform-plugin-docs v0.18.0/go.mod h1:iIUfaJpdUmpi+rI42Kgq+63jAjI8aZVTyxp3Bvk9Hg8=
github.com/hashicorp/terraform-plugin-framework v1.6.1 h1:hw2XrmUu8d8jVL52ekxim2IqDc+2Kpekn21xZANARLU=
github.com/hashicorp/terraform-plugin-framework v1.6.1/go.mod h1:aJI+n/hBPhz1J+77GdgNfk5svW12y7fmtxe/5L5IuwI=
github.com/hashicorp/terraform-plugin-framework-validators v0.12.0 h1:HOjBuMbOEzl7snOdOoUfE2Jgeto6JOjLVQ39Ls2nksc=
github.com/hashicorp/terraform-plugin-framework-validators v0.12.0/go.mod h1:jfHGE/gzjxYz6XoUwi/aYiiKrJDeutQNUtGQXkaHklg=
github.com/hashicorp/terraform-plugin-go v0.22.1 h1:iTS7WHNVrn7uhe3cojtvWWn83cm2Z6ryIUDTRO0EV7w=
github.com/hashicorp/terraform-plugin-go v0.22.1/go.mod h1:qrjnqRghvQ6KnDbB12XeZ4FluclYwptntoWCr9QaXTI=
github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0=
Expand Down
17 changes: 14 additions & 3 deletions internal/common/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import (
"fmt"
"strings"

"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)

type Resource struct {
Name string
IDType *ResourceID
Schema *schema.Resource
Name string
IDType *ResourceID
Schema *schema.Resource
PluginFrameworkSchema resource.ResourceWithConfigure
}

func NewResource(name string, idType *ResourceID, schema *schema.Resource) *Resource {
Expand All @@ -22,6 +24,15 @@ func NewResource(name string, idType *ResourceID, schema *schema.Resource) *Reso
return r
}

func NewPluginFrameworkResource(name string, idType *ResourceID, schema resource.ResourceWithConfigure) *Resource {
r := &Resource{
Name: name,
IDType: idType,
PluginFrameworkSchema: schema,
}
return r
}

func (r *Resource) ImportExample() string {
id := r.IDType
fields := make([]string, len(id.expectedFields))
Expand Down
30 changes: 30 additions & 0 deletions internal/resources/cloud/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package cloud

import (
"context"
"fmt"

"github.com/grafana/grafana-com-public-clients/go/gcom"
"github.com/grafana/terraform-provider-grafana/v2/internal/common"
"github.com/hashicorp/go-uuid"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
Expand Down Expand Up @@ -46,3 +48,31 @@ func apiError(err error) diag.Diagnostics {
},
}
}

type basePluginFrameworkResource struct {
client *gcom.APIClient
}

func (r *basePluginFrameworkResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
resp.Diagnostics.AddError(
"Unconfigured Cloud API client",
"the Cloud API client is required for this resource. Set the cloud_access_policy_token provider attribute",
)

return
}

client, ok := req.ProviderData.(*common.Client)

if !ok {
resp.Diagnostics.AddError(
"Unexpected Resource Configure Type",
fmt.Sprintf("Expected *common.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData),
)

return
}

r.client = client.GrafanaCloudAPI
}
239 changes: 239 additions & 0 deletions internal/resources/cloud/resource_cloud_org_member.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
package cloud

import (
"context"
"net/http"

"github.com/grafana/grafana-com-public-clients/go/gcom"
"github.com/grafana/terraform-provider-grafana/v2/internal/common"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
"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"
"github.com/hashicorp/terraform-plugin-framework/types"
)

var (
resourceOrgMemberName = "grafana_cloud_org_member"
resourceOrgMemberID = common.NewResourceID(common.StringIDField("orgSlugOrID"), common.StringIDField("usernameOrID"))

// Check interface
_ resource.ResourceWithImportState = (*orgMemberResource)(nil)
)

func resourceOrgMember() *common.Resource {
return common.NewPluginFrameworkResource(resourceOrgMemberName, resourceOrgMemberID, &orgMemberResource{})
}

type resourceOrgMemberModel struct {
ID types.String `tfsdk:"id"`
Org types.String `tfsdk:"org"`
User types.String `tfsdk:"user"`
Role types.String `tfsdk:"role"`
ReceiveBillingEmails types.Bool `tfsdk:"receive_billing_emails"`
}

type orgMemberResource struct {
basePluginFrameworkResource
}

func (r *orgMemberResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = resourceOrgMemberName
}

func (r *orgMemberResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
},
"org": schema.StringAttribute{
MarkdownDescription: "The slug or ID of the organization.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"user": schema.StringAttribute{
MarkdownDescription: "Username or ID of the user to add to the org's members.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"role": schema.StringAttribute{
MarkdownDescription: "The role to assign to the user in the organization.",
Required: true,
Validators: []validator.String{
stringvalidator.OneOf("Admin", "Editor", "Viewer", "None"),
},
},
"receive_billing_emails": schema.BoolAttribute{
MarkdownDescription: "Whether the user should receive billing emails.",
Optional: true,
Computed: true,
Default: booldefault.StaticBool(false),
},
},
MarkdownDescription: "Manages the membership of a user in an organization.",
}
}

func (r *orgMemberResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
_, err := resourceOrgMemberID.Split(req.ID)
if err != nil {
resp.Diagnostics.AddError("Invalid ID", "Unable to decode ID")
return
}

data, diags := r.readFromID(ctx, req.ID)
if diags != nil {
resp.Diagnostics = diags
return
}
if data == nil {
resp.Diagnostics.AddError("Resource not found", "Resource not found")
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, data)...)
}

func (r *orgMemberResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
// Read Terraform plan data into the model
var data resourceOrgMemberModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
id := resourceOrgMemberID.Make(data.Org.ValueString(), data.User.ValueString())

// POST
var billing int32 = 0
if data.ReceiveBillingEmails.ValueBool() {
billing = 1
}
postReq := gcom.NewPostOrgMembersRequest(data.User.ValueString())
postReq.SetBilling(billing)
postReq.SetRole(data.Role.ValueString())
_, _, err := r.client.OrgsAPI.PostOrgMembers(ctx, data.Org.ValueString()).PostOrgMembersRequest(*postReq).XRequestId(ClientRequestID()).Execute()
if err != nil {
resp.Diagnostics.AddError("Unable to Create Resource", err.Error())
return
}

// Read created resource
readData, diags := r.readFromID(ctx, id)
if diags != nil {
resp.Diagnostics = diags
return
}
if readData == nil {
resp.Diagnostics.AddError("Unable to read created resource", "Resource not found")
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, readData)...)
}

func (r *orgMemberResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
// Read Terraform state data into the model
var data resourceOrgMemberModel
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)

// Read from API
readData, diags := r.readFromID(ctx, data.ID.ValueString())
if diags != nil {
resp.Diagnostics = diags
return
}
if readData == nil {
resp.State.RemoveResource(ctx)
return
}

// Save data into Terraform state
resp.Diagnostics.Append(resp.State.Set(ctx, readData)...)
}

func (r *orgMemberResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
// Read Terraform plan data into the model
var data resourceOrgMemberModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
id := resourceOrgMemberID.Make(data.Org.ValueString(), data.User.ValueString())

// POST
var billing int32 = 0
if data.ReceiveBillingEmails.ValueBool() {
billing = 1
}
postReq := gcom.NewPostOrgMemberRequest()
postReq.SetBilling(billing)
postReq.SetRole(data.Role.ValueString())
if _, _, err := r.client.OrgsAPI.PostOrgMember(ctx, data.Org.ValueString(), data.User.ValueString()).XRequestId(ClientRequestID()).PostOrgMemberRequest(*postReq).Execute(); err != nil {
resp.Diagnostics.AddError("Unable to Update Resource", err.Error())
return
}

// Read updated resource
readData, diags := r.readFromID(ctx, id)
if diags != nil {
resp.Diagnostics = diags
return
}
if readData == nil {
resp.Diagnostics.AddError("Unable to read updated resource", "Resource not found")
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, readData)...)
}

func (r *orgMemberResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
// Read Terraform prior state data into the model
var data resourceOrgMemberModel
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)

split, err := resourceOrgMemberID.Split(data.ID.ValueString())
if err != nil {
resp.Diagnostics.AddError("Unable to split ID", err.Error())
return
}
org, user := split[0].(string), split[1].(string)

// DELETE
if _, err := r.client.OrgsAPI.DeleteOrgMember(ctx, org, user).XRequestId(ClientRequestID()).Execute(); err != nil {
resp.Diagnostics.AddError("Unable to Delete Resource", err.Error())
}
}

func (r *orgMemberResource) readFromID(ctx context.Context, id string) (*resourceOrgMemberModel, diag.Diagnostics) {
split, err := resourceOrgMemberID.Split(id)
if err != nil {
return nil, diag.Diagnostics{diag.NewErrorDiagnostic("Unable to split ID", err.Error())}
}
org, user := split[0].(string), split[1].(string)

// GET
memberResp, httpResp, err := r.client.OrgsAPI.GetOrgMember(ctx, org, user).Execute()
if httpResp.StatusCode == http.StatusNotFound {
return nil, nil
}

if err != nil {
return nil, diag.Diagnostics{diag.NewErrorDiagnostic("Unable to read resource", err.Error())}
}

data := &resourceOrgMemberModel{}
data.ID = types.StringValue(id)
data.Org = types.StringValue(org)
data.User = types.StringValue(user)
data.Role = types.StringValue(memberResp.Role)
data.ReceiveBillingEmails = types.BoolValue(memberResp.Billing == 1)

return data, nil
}
Loading

0 comments on commit 86780e0

Please sign in to comment.