diff --git a/docs/resources/logdna_member.md b/docs/resources/logdna_member.md new file mode 100644 index 0000000..de3dfb8 --- /dev/null +++ b/docs/resources/logdna_member.md @@ -0,0 +1,40 @@ +# Resource: `logdna_member` + +This resource allows you to manage the team members of an organization. + +## Example + +```hcl +provider "logdna" { + servicekey = "xxxxxxxxxxxxxxxxxxxxxxxx" +} + +resource "logdna_member" "admin_user" { + email = "user@domain.jp.co" + role = "admin" +} +``` + +## Argument Reference + +The following arguments are supported: + +- `email`: **string** _(Required)_ The email of the user. If a user with that email does not exist, they will be invited to join Mezmo. +- `role`: **string** _(Required)_ The role of this user. Can be one of `admin`, `member`, and `readonly`. `owner` roles can only be changed through the UI. +- `groups`: **string[]** _(Optional)_ The id of the groups the user belongs to. Defaults to an empty list. + +## Attributes Reference + +In addition to all the arguments above, the following attributes are exported: + +- `email`: **string** The email of the member. +- `role`: **string** The role of the member. +- `groups`: **string[]** The groups the member belongs to. + +## Import + +A member can be imported using their `email`, e.g., + +```sh +$ terraform import logdna_member.user1 +``` diff --git a/logdna/provider.go b/logdna/provider.go index 1c452e1..3d08946 100644 --- a/logdna/provider.go +++ b/logdna/provider.go @@ -40,6 +40,7 @@ func Provider() *schema.Provider { "logdna_archive": resourceArchiveConfig(), "logdna_key": resourceKey(), "logdna_index_rate_alert": resourceIndexRateAlert(), + "logdna_member": resourceMember(), }, ConfigureFunc: providerConfigure, } diff --git a/logdna/request.go b/logdna/request.go index 9da6c0a..6f7aff0 100644 --- a/logdna/request.go +++ b/logdna/request.go @@ -31,8 +31,8 @@ type requestConfig struct { // newRequestConfig abstracts the struct creation to allow for mocking func newRequestConfig(pc *providerConfig, method string, uri string, body interface{}, mutators ...func(*requestConfig)) *requestConfig { rc := &requestConfig{ - serviceKey: pc.serviceKey, - httpClient: pc.httpClient, + serviceKey: pc.serviceKey, + httpClient: pc.httpClient, apiURL: fmt.Sprintf("%s%s", pc.baseURL, uri), // uri should have a preceding slash (/) method: method, body: body, diff --git a/logdna/request_types.go b/logdna/request_types.go index cc433b8..fc628bf 100644 --- a/logdna/request_types.go +++ b/logdna/request_types.go @@ -6,8 +6,8 @@ package logdna import ( "encoding/json" - "fmt" "errors" + "fmt" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" @@ -70,6 +70,17 @@ type indexRateAlertRequest struct { Enabled bool `json:"enabled,omitempty"` } +type memberRequest struct { + Email string `json:"email,omitempty"` + Role string `json:"role,omitempty"` + Groups []string `json:"groups,omitempty"` +} + +type memberPutRequest struct { + Role string `json:"role,omitempty"` + Groups []string `json:"groups"` +} + func (view *viewRequest) CreateRequestBody(d *schema.ResourceData) diag.Diagnostics { // This function pulls from the schema in preparation to JSON marshal var diags diag.Diagnostics @@ -135,24 +146,45 @@ func (doc *indexRateAlertRequest) CreateRequestBody(d *schema.ResourceData) diag ) } - doc.MaxLines = d.Get("max_lines").(int) - doc.MaxZScore = d.Get("max_z_score").(int) - doc.Enabled = d.Get("enabled").(bool) + doc.MaxLines = d.Get("max_lines").(int) + doc.MaxZScore = d.Get("max_z_score").(int) + doc.Enabled = d.Get("enabled").(bool) doc.ThresholdAlert = d.Get("threshold_alert").(string) - doc.Frequency = d.Get("frequency").(string) + doc.Frequency = d.Get("frequency").(string) var indexRateAlertChannel indexRateAlertChannelRequest var channel = channels[0].(map[string]interface{}) - indexRateAlertChannel.Email = listToStrings(channel["email"].([]interface{})) + indexRateAlertChannel.Email = listToStrings(channel["email"].([]interface{})) indexRateAlertChannel.Pagerduty = listToStrings(channel["pagerduty"].([]interface{})) - indexRateAlertChannel.Slack = listToStrings(channel["slack"].([]interface{})) + indexRateAlertChannel.Slack = listToStrings(channel["slack"].([]interface{})) doc.Channels = indexRateAlertChannel return diags } +func (member *memberRequest) CreateRequestBody(d *schema.ResourceData) diag.Diagnostics { + var diags diag.Diagnostics + + // Scalars + member.Email = d.Get("email").(string) + member.Role = d.Get("role").(string) + member.Groups = listToStrings(d.Get("groups").([]interface{})) + + return diags +} + +func (member *memberPutRequest) CreateRequestBody(d *schema.ResourceData) diag.Diagnostics { + var diags diag.Diagnostics + + // Scalars + member.Role = d.Get("role").(string) + member.Groups = listToStrings(d.Get("groups").([]interface{})) + + return diags +} + func aggregateAllChannelsFromSchema( d *schema.ResourceData, diags *diag.Diagnostics, diff --git a/logdna/resource_member.go b/logdna/resource_member.go new file mode 100644 index 0000000..22967d0 --- /dev/null +++ b/logdna/resource_member.go @@ -0,0 +1,177 @@ +package logdna + +import ( + "context" + "encoding/json" + "fmt" + "log" + "strings" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +func resourceMemberCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + var diags diag.Diagnostics + pc := m.(*providerConfig) + + member := memberRequest{} + + if diags = member.CreateRequestBody(d); diags.HasError() { + return diags + } + + req := newRequestConfig( + pc, + "POST", + "/v1/config/members", + member, + ) + body, err := req.MakeRequest() + log.Printf("[DEBUG] %s %s, payload is: %s", req.method, req.apiURL, body) + + if err != nil { + return diag.FromErr(err) + } + + createdMember := memberResponse{} + err = json.Unmarshal(body, &createdMember) + if err != nil { + return diag.FromErr(err) + } + log.Printf("[DEBUG] After %s member, the created member is %+v", req.method, createdMember) + + d.SetId(createdMember.Email) + + return resourceMemberRead(ctx, d, m) +} + +func resourceMemberRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + var diags diag.Diagnostics + + pc := m.(*providerConfig) + memberID := d.Id() + + req := newRequestConfig( + pc, + "GET", + fmt.Sprintf("/v1/config/members/%s", memberID), + nil, + ) + + body, err := req.MakeRequest() + + log.Printf("[DEBUG] GET member raw response body %s\n", body) + if err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Cannot read the remote member resource", + Detail: err.Error(), + }) + return diags + } + + member := memberResponse{} + err = json.Unmarshal(body, &member) + if err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Cannot unmarshal response from the remote member resource", + Detail: err.Error(), + }) + return diags + } + log.Printf("[DEBUG] The GET member structure is as follows: %+v\n", member) + + // Top level keys can be set directly + appendError(d.Set("email", member.Email), &diags) + appendError(d.Set("role", member.Role), &diags) + appendError(d.Set("groups", member.Groups), &diags) + + return diags +} + +func resourceMemberUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + var diags diag.Diagnostics + pc := m.(*providerConfig) + memberID := d.Id() + + member := memberPutRequest{} + if diags = member.CreateRequestBody(d); diags.HasError() { + return diags + } + + req := newRequestConfig( + pc, + "PUT", + fmt.Sprintf("/v1/config/members/%s", memberID), + member, + ) + + body, err := req.MakeRequest() + log.Printf("[DEBUG] %s %s, payload is: %s", req.method, req.apiURL, body) + + if err != nil { + return diag.FromErr(err) + } + + log.Printf("[DEBUG] %s %s SUCCESS. Remote resource updated.", req.method, req.apiURL) + + return resourceMemberRead(ctx, d, m) +} + +func resourceMemberDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + pc := m.(*providerConfig) + memberID := d.Id() + + req := newRequestConfig( + pc, + "DELETE", + fmt.Sprintf("/v1/config/members/%s", memberID), + nil, + ) + + body, err := req.MakeRequest() + log.Printf("[DEBUG] %s %s key %s", req.method, req.apiURL, body) + + if err != nil { + return diag.FromErr(err) + } + + d.SetId("") + return nil +} + +func resourceMember() *schema.Resource { + return &schema.Resource{ + CreateContext: resourceMemberCreate, + UpdateContext: resourceMemberUpdate, + ReadContext: resourceMemberRead, + DeleteContext: resourceMemberDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Schema: map[string]*schema.Schema{ + "email": { + Type: schema.TypeString, + ForceNew: true, + Required: true, + DiffSuppressFunc: func(k, old, new string, d *schema.ResourceData) bool { + return strings.EqualFold(old, new) + }, + }, + "role": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringInSlice([]string{"owner", "admin", "member", "readonly"}, false), + }, + "groups": { + Type: schema.TypeList, + Elem: &schema.Schema{Type: schema.TypeString}, + Optional: true, + }, + }, + } +} diff --git a/logdna/resource_member_test.go b/logdna/resource_member_test.go new file mode 100644 index 0000000..255d52f --- /dev/null +++ b/logdna/resource_member_test.go @@ -0,0 +1,68 @@ +package logdna + +import ( + "regexp" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +func TestMember_ErrorRoleEmpty(t *testing.T) { + args := map[string]string{ + "email": `"user@example.org"`, + } + + resource.Test(t, resource.TestCase{ + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: fmtTestConfigResource("member", "new", []string{serviceKey, apiHostUrl}, args, nilOpt, nilLst), + ExpectError: regexp.MustCompile("The argument \"role\" is required, but no definition was found."), + }, + }, + }) +} + +func TestMember_Basic(t *testing.T) { + memberArgs := map[string]string{ + "email": `"member@example.org"`, + "role": `"member"`, + } + + adminArgs := map[string]string{ + "email": `"admin@example.org"`, + "role": `"admin"`, + } + resource.Test(t, resource.TestCase{ + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: fmtTestConfigResource("member", "member", []string{serviceKey, apiHostUrl}, memberArgs, nilOpt, nilLst), + Check: resource.ComposeTestCheckFunc( + testResourceExists("member", "member"), + resource.TestCheckResourceAttr("logdna_member.member", "email", strings.Replace(memberArgs["email"], "\"", "", 2)), + resource.TestCheckResourceAttr("logdna_member.member", "role", strings.Replace(memberArgs["role"], "\"", "", 2)), + ), + }, + { + ResourceName: "logdna_member.member", + ImportState: true, + ImportStateVerify: true, + }, + { + Config: fmtTestConfigResource("member", "admin", []string{serviceKey, apiHostUrl}, adminArgs, nilOpt, nilLst), + Check: resource.ComposeTestCheckFunc( + testResourceExists("member", "admin"), + resource.TestCheckResourceAttr("logdna_member.admin", "email", strings.Replace(adminArgs["email"], "\"", "", 2)), + resource.TestCheckResourceAttr("logdna_member.admin", "role", strings.Replace(adminArgs["role"], "\"", "", 2)), + ), + }, + { + ResourceName: "logdna_member.admin", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} diff --git a/logdna/response_types.go b/logdna/response_types.go index af8bf21..34439f9 100644 --- a/logdna/response_types.go +++ b/logdna/response_types.go @@ -39,6 +39,12 @@ type keyResponse struct { Created int `json:"created,omitempty"` } +type memberResponse struct { + Email string `json:"email"` + Role string `json:"role"` + Groups []string `json:"groups,omitempty"` +} + // channelResponse contains channel data returned from the logdna APIs // NOTE - Properties with `interface` are due to the APIs returning // some things as strings (PUT/emails) and other times arrays (GET/emails)