diff --git a/.github/workflows/acc-test.yml b/.github/workflows/acc-test.yml index 6addbada..83111ebf 100644 --- a/.github/workflows/acc-test.yml +++ b/.github/workflows/acc-test.yml @@ -30,6 +30,6 @@ jobs: CORALOGIX_ENV: ${{ secrets.CORALOGIX_ENV }} CORALOGIX_API_KEY: ${{ secrets.CORALOGIX_API_KEY }} CORALOGIX_ORG_KEY: ${{ secrets.CORALOGIX_ORG_KEY }} - + TEST_TEAM_ID: ${{ secrets.TEST_TEAM_ID }} run: | make testacc \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c2b1f48..89566047 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -454,4 +454,16 @@ Bug fixing: ## Release 1.11.7 Bug fixing: #### resource/coralogix_dashboard -* fixing flatten of `json_content` field bug. \ No newline at end of file +* fixing flatten of `json_content` field bug. + +## Release 1.11.8 +New Features: +#### resource/coralogix_api_key +* Adding `coralogix_api_key` [resource](https://github.com/coralogix/terraform-provider-coralogix/tree/master/docs/resources/api_key.md) and [data-source](https://github.com/coralogix/terraform-provider-coralogix/tree/master/docs/data-sources/api_key.md). + +## Release 1.11.9 +New Features: +#### resource/coralogix_user +* Adding `coralogix_user` [resource](https://github.com/coralogix/terraform-provider-coralogix/tree/master/docs/resources/user.md) and [data-source](https://github.com/coralogix/terraform-provider-coralogix/tree/master/docs/data-sources/user.md). +#### resource/coralogix_group +* Adding `coralogix_user_group` [resource](https://github.com/coralogix/terraform-provider-coralogix/tree/master/docs/resources/group.md) and [data-source](https://github.com/coralogix/terraform-provider-coralogix/tree/master/docs/data-sources/group.md). \ No newline at end of file diff --git a/coralogix/clientset/clientset.go b/coralogix/clientset/clientset.go index 9ef2b949..cb70b98d 100644 --- a/coralogix/clientset/clientset.go +++ b/coralogix/clientset/clientset.go @@ -21,6 +21,8 @@ type ClientSet struct { slos *SLOsClient dahboardsFolders *DashboardsFoldersClient apiKeys *ApikeysClient + groups *GroupsClient + users *UsersClient } func (c *ClientSet) RuleGroups() *RuleGroupsClient { @@ -103,6 +105,14 @@ func (c *ClientSet) DashboardsFolders() *DashboardsFoldersClient { return c.dahboardsFolders } +func (c *ClientSet) Groups() *GroupsClient { + return c.groups +} + +func (c *ClientSet) Users() *UsersClient { + return c.users +} + func NewClientSet(targetUrl, apiKey, orgKey string) *ClientSet { apikeyCPC := NewCallPropertiesCreator(targetUrl, apiKey) teamsCPC := NewCallPropertiesCreator(targetUrl, orgKey) @@ -128,5 +138,7 @@ func NewClientSet(targetUrl, apiKey, orgKey string) *ClientSet { slos: NewSLOsClient(apikeyCPC), dahboardsFolders: NewDashboardsFoldersClient(apikeyCPC), apiKeys: NewApiKeysClient(teamsCPC), + groups: NewGroupsClient(teamsCPC), + users: NewUsersClient(teamsCPC), } } diff --git a/coralogix/clientset/groups-client.go b/coralogix/clientset/groups-client.go new file mode 100644 index 00000000..8b76f927 --- /dev/null +++ b/coralogix/clientset/groups-client.go @@ -0,0 +1,93 @@ +package clientset + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "terraform-provider-coralogix/coralogix/clientset/rest" +) + +type GroupsClient struct { + client *rest.Client + TargetUrl string +} + +type SCIMGroup struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + Members []SCIMGroupMember `json:"members"` + Role string `json:"role"` +} + +type SCIMGroupMember struct { + Value string `json:"value"` +} + +func (c GroupsClient) CreateGroup(ctx context.Context, teamID string, groupReq *SCIMGroup) (*SCIMGroup, error) { + body, err := json.Marshal(groupReq) + if err != nil { + return nil, err + } + + bodyResp, err := c.client.Post(ctx, "", "application/json", string(body), "cgx-team-id", teamID) + if err != nil { + return nil, err + } + + var groupResp SCIMGroup + err = json.Unmarshal([]byte(bodyResp), &groupResp) + if err != nil { + return nil, err + } + + return &groupResp, nil +} + +func (c GroupsClient) GetGroup(ctx context.Context, teamID, groupID string) (*SCIMGroup, error) { + bodyResp, err := c.client.Get(ctx, fmt.Sprintf("/%s", groupID), "cgx-team-id", teamID) + if err != nil { + return nil, err + } + + var groupResp SCIMGroup + err = json.Unmarshal([]byte(bodyResp), &groupResp) + if err != nil { + return nil, err + } + + return &groupResp, nil +} + +func (c GroupsClient) UpdateGroup(ctx context.Context, teamID, groupID string, groupReq *SCIMGroup) (*SCIMGroup, error) { + body, err := json.Marshal(groupReq) + if err != nil { + return nil, err + } + + bodyResp, err := c.client.Put(ctx, fmt.Sprintf("/%s", groupID), "application/json", string(body), "cgx-team-id", teamID) + if err != nil { + return nil, err + } + + var groupResp SCIMGroup + err = json.Unmarshal([]byte(bodyResp), &groupResp) + if err != nil { + return nil, err + } + + return &groupResp, nil +} + +func (c GroupsClient) DeleteGroup(ctx context.Context, teamID, groupID string) error { + _, err := c.client.Delete(ctx, fmt.Sprintf("/%s", groupID), "cgx-team-id", teamID) + return err + +} + +func NewGroupsClient(c *CallPropertiesCreator) *GroupsClient { + targetUrl := "https://" + strings.Replace(c.targetUrl, "grpc", "http", 1) + "/scim/Groups" + client := rest.NewRestClient(targetUrl, c.apiKey) + return &GroupsClient{client: client, TargetUrl: targetUrl} +} diff --git a/coralogix/clientset/rest/client.go b/coralogix/clientset/rest/client.go index a65c0f41..b652aa8d 100644 --- a/coralogix/clientset/rest/client.go +++ b/coralogix/clientset/rest/client.go @@ -24,7 +24,7 @@ func NewRestClient(url string, apiKey string) *Client { } // Request executes request to Coralogix API -func (c *Client) Request(ctx context.Context, method, path, contentType string, body interface{}) (string, error) { +func (c *Client) Request(ctx context.Context, method, path, contentType string, body interface{}, headers ...string) (string, error) { var request *http.Request if body != nil { bodyReader := bytes.NewBuffer([]byte(body.(string))) @@ -42,6 +42,12 @@ func (c *Client) Request(ctx context.Context, method, path, contentType string, request = request.WithContext(ctx) request.Header.Set("Cache-Control", "no-cache") request.Header.Set("Authorization", "Bearer "+c.apiKey) + if len(headers)%2 != 0 { + return "", fmt.Errorf("invalid headers, must be key-value pairs") + } + for i := 0; i < len(headers); i += 2 { + request.Header.Set(headers[i], headers[i+1]) + } response, err := c.client.Do(request) if err != nil { @@ -72,21 +78,21 @@ func (c *Client) Request(ctx context.Context, method, path, contentType string, } // Get executes GET request to Coralogix API -func (c *Client) Get(ctx context.Context, path string) (string, error) { - return c.Request(ctx, "GET", path, "", nil) +func (c *Client) Get(ctx context.Context, path string, headers ...string) (string, error) { + return c.Request(ctx, "GET", path, "", nil, headers...) } // Post executes POST request to Coralogix API -func (c *Client) Post(ctx context.Context, path, contentType, body string) (string, error) { - return c.Request(ctx, "POST", path, contentType, body) +func (c *Client) Post(ctx context.Context, path, contentType, body string, headers ...string) (string, error) { + return c.Request(ctx, "POST", path, contentType, body, headers...) } // Put executes PUT request to Coralogix API -func (c *Client) Put(ctx context.Context, path, contentType, body string) (string, error) { - return c.Request(ctx, "PUT", path, contentType, body) +func (c *Client) Put(ctx context.Context, path, contentType, body string, headers ...string) (string, error) { + return c.Request(ctx, "PUT", path, contentType, body, headers...) } // Delete executes DELETE request to Coralogix API -func (c *Client) Delete(ctx context.Context, path string) (string, error) { - return c.Request(ctx, "DELETE", path, "", nil) +func (c *Client) Delete(ctx context.Context, path string, headers ...string) (string, error) { + return c.Request(ctx, "DELETE", path, "", nil, headers...) } diff --git a/coralogix/clientset/users-client.go b/coralogix/clientset/users-client.go new file mode 100644 index 00000000..11d949eb --- /dev/null +++ b/coralogix/clientset/users-client.go @@ -0,0 +1,107 @@ +package clientset + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "terraform-provider-coralogix/coralogix/clientset/rest" +) + +type UsersClient struct { + client *rest.Client + TargetUrl string +} + +type SCIMUser struct { + Schemas []string `json:"schemas"` + ID *string `json:"id,omitempty"` + UserName string `json:"userName"` + Active bool `json:"active"` + Name *SCIMUserName `json:"name,omitempty"` + Groups []SCIMUserGroup `json:"groups,omitempty"` + Emails []SCIMUserEmail `json:"emails,omitempty"` +} + +type SCIMUserName struct { + GivenName string `json:"givenName"` + FamilyName string `json:"familyName"` +} + +type SCIMUserEmail struct { + Value string `json:"value"` + Primary bool `json:"primary"` + Type string `json:"type"` +} + +type SCIMUserGroup struct { + Value string `json:"value"` +} + +func (c UsersClient) CreateUser(ctx context.Context, teamID string, userReq *SCIMUser) (*SCIMUser, error) { + body, err := json.Marshal(userReq) + if err != nil { + return nil, err + } + + bodyResp, err := c.client.Post(ctx, "", "application/json", string(body), "cgx-team-id", teamID) + if err != nil { + return nil, err + } + + var UserResp SCIMUser + err = json.Unmarshal([]byte(bodyResp), &UserResp) + if err != nil { + return nil, err + } + + return &UserResp, nil +} + +func (c UsersClient) GetUser(ctx context.Context, teamID, userID string) (*SCIMUser, error) { + bodyResp, err := c.client.Get(ctx, fmt.Sprintf("/%s", userID), "cgx-team-id", teamID) + if err != nil { + return nil, err + } + + var UserResp SCIMUser + err = json.Unmarshal([]byte(bodyResp), &UserResp) + if err != nil { + return nil, err + } + + return &UserResp, nil +} + +func (c UsersClient) UpdateUser(ctx context.Context, teamID, userID string, userReq *SCIMUser) (*SCIMUser, error) { + body, err := json.Marshal(userReq) + if err != nil { + return nil, err + } + + bodyResp, err := c.client.Put(ctx, fmt.Sprintf("/%s", userID), "application/json", string(body), "cgx-team-id", teamID) + if err != nil { + return nil, err + } + + var UserResp SCIMUser + err = json.Unmarshal([]byte(bodyResp), &UserResp) + if err != nil { + return nil, err + } + + return &UserResp, nil +} + +func (c UsersClient) DeleteUser(ctx context.Context, teamID, userID string) error { + _, err := c.client.Delete(ctx, fmt.Sprintf("/%s", userID), "cgx-team-id", teamID) + return err + +} + +func NewUsersClient(c *CallPropertiesCreator) *UsersClient { + targetUrl := "https://" + strings.Replace(c.targetUrl, "grpc", "http", 1) + "/scim/Users" + client := rest.NewRestClient(targetUrl, c.apiKey) + return &UsersClient{client: client, TargetUrl: targetUrl} +} diff --git a/coralogix/data_source_coralogix_api_key_test.go b/coralogix/data_source_coralogix_api_key_test.go index 0da7b674..9bdac1d7 100644 --- a/coralogix/data_source_coralogix_api_key_test.go +++ b/coralogix/data_source_coralogix_api_key_test.go @@ -18,7 +18,7 @@ func TestAccCoralogixDataSourceApiKey(t *testing.T) { testApiKeyResource_read(), Check: resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttr(apiKeyDataSourceName, "name", "Test Key 3"), - resource.TestCheckResourceAttr(apiKeyDataSourceName, "owner.team_id", targetTeam), + resource.TestCheckResourceAttr(apiKeyDataSourceName, "owner.team_id", teamID), resource.TestCheckResourceAttr(apiKeyDataSourceName, "active", "true"), resource.TestCheckResourceAttr(apiKeyDataSourceName, "hashed", "false"), ), diff --git a/coralogix/data_source_coralogix_group.go b/coralogix/data_source_coralogix_group.go new file mode 100644 index 00000000..c83e171f --- /dev/null +++ b/coralogix/data_source_coralogix_group.go @@ -0,0 +1,96 @@ +package coralogix + +import ( + "context" + "encoding/json" + "fmt" + "log" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "terraform-provider-coralogix/coralogix/clientset" +) + +var _ datasource.DataSourceWithConfigure = &GroupDataSource{} + +func NewGroupDataSource() datasource.DataSource { + return &GroupDataSource{} +} + +type GroupDataSource struct { + client *clientset.GroupsClient +} + +func (d *GroupDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_group" +} + +func (d *GroupDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + clientSet, ok := req.ProviderData.(*clientset.ClientSet) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *clientset.ClientSet, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + d.client = clientSet.Groups() +} + +func (d *GroupDataSource) Schema(ctx context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + var r GroupResource + var resourceResp resource.SchemaResponse + r.Schema(ctx, resource.SchemaRequest{}, &resourceResp) + + resp.Schema = frameworkDatasourceSchemaFromFrameworkResourceSchemaWithTeamID(resourceResp.Schema) +} + +func (d *GroupDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var data *GroupResourceModel + diags := req.Config.Get(ctx, &data) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + //Get refreshed Group value from Coralogix + id := data.ID.ValueString() + log.Printf("[INFO] Reading Group: %s", id) + teamID := data.TeamID.ValueString() + getGroupResp, err := d.client.GetGroup(ctx, teamID, id) + if err != nil { + log.Printf("[ERROR] Received error: %#v", err) + if status.Code(err) == codes.NotFound { + data.ID = types.StringNull() + resp.Diagnostics.AddWarning( + fmt.Sprintf("Group %q is in state, but no longer exists in Coralogix backend", id), + fmt.Sprintf("%s will be recreated when you apply", id), + ) + } else { + resp.Diagnostics.AddError( + "Error reading Group", + formatRpcErrors(err, fmt.Sprintf("%s/%s", d.client.TargetUrl, id), ""), + ) + } + return + } + respStr, _ := json.Marshal(getGroupResp) + log.Printf("[INFO] Received Group: %s", string(respStr)) + + data, diags = flattenSCIMGroup(getGroupResp) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + // Set state to fully populated data + diags = resp.State.Set(ctx, &data) + resp.Diagnostics.Append(diags...) +} diff --git a/coralogix/data_source_coralogix_group_test.go b/coralogix/data_source_coralogix_group_test.go new file mode 100644 index 00000000..af4cacae --- /dev/null +++ b/coralogix/data_source_coralogix_group_test.go @@ -0,0 +1,35 @@ +package coralogix + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +var groupDataSourceName = "data." + groupResourceName + +func TestAccCoralogixDataSourceGroup_basic(t *testing.T) { + userName := randUserName() + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccCoralogixResourceGroup(userName) + + testAccCoralogixDataSourceGroup_read(), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(groupDataSourceName, "display_name", "example"), + ), + }, + }, + }) +} + +func testAccCoralogixDataSourceGroup_read() string { + return fmt.Sprintf(`data "coralogix_group" "test" { + id = coralogix_group.test.id + team_id = "%s" +} +`, teamID) +} diff --git a/coralogix/data_source_coralogix_user.go b/coralogix/data_source_coralogix_user.go new file mode 100644 index 00000000..609906af --- /dev/null +++ b/coralogix/data_source_coralogix_user.go @@ -0,0 +1,97 @@ +package coralogix + +import ( + "context" + "encoding/json" + "fmt" + "log" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "terraform-provider-coralogix/coralogix/clientset" +) + +var _ datasource.DataSourceWithConfigure = &UserDataSource{} + +func NewUserDataSource() datasource.DataSource { + return &UserDataSource{} +} + +type UserDataSource struct { + client *clientset.UsersClient +} + +func (d *UserDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_user" +} + +func (d *UserDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + clientSet, ok := req.ProviderData.(*clientset.ClientSet) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *clientset.ClientSet, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + d.client = clientSet.Users() +} + +func (d *UserDataSource) Schema(ctx context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + var r UserResource + var resourceResp resource.SchemaResponse + r.Schema(ctx, resource.SchemaRequest{}, &resourceResp) + + resp.Schema = frameworkDatasourceSchemaFromFrameworkResourceSchemaWithTeamID(resourceResp.Schema) +} + +func (d *UserDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var data *UserResourceModel + diags := req.Config.Get(ctx, &data) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + //Get refreshed User value from Coralogix + id := data.ID.ValueString() + teamId := data.TeamID.ValueString() + log.Printf("[INFO] Reading User: %s", id) + getUserResp, err := d.client.GetUser(ctx, teamId, id) + if err != nil { + log.Printf("[ERROR] Received error: %#v", err) + if status.Code(err) == codes.NotFound { + data.ID = types.StringNull() + resp.Diagnostics.AddWarning( + fmt.Sprintf("User %q is in state, but no longer exists in Coralogix backend", id), + fmt.Sprintf("%s will be recreated when you apply", id), + ) + } else { + resp.Diagnostics.AddError( + "Error reading User", + formatRpcErrors(err, fmt.Sprintf("%s/%s", d.client.TargetUrl, id), ""), + ) + } + return + } + respStr, _ := json.Marshal(getUserResp) + log.Printf("[INFO] Received User: %s", string(respStr)) + + data, diags = flattenSCIMUser(ctx, getUserResp) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + data.TeamID = types.StringValue(teamId) + + // Set state to fully populated data + diags = resp.State.Set(ctx, &data) + resp.Diagnostics.Append(diags...) +} diff --git a/coralogix/data_source_coralogix_user_test.go b/coralogix/data_source_coralogix_user_test.go new file mode 100644 index 00000000..49914190 --- /dev/null +++ b/coralogix/data_source_coralogix_user_test.go @@ -0,0 +1,35 @@ +package coralogix + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +var userDataSourceName = "data." + userResourceName + +func TestAccCoralogixDataSourceUser_basic(t *testing.T) { + userName := randUserName() + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccCoralogixResourceUser(userName) + + testAccCoralogixDataSourceUser_read(), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(userDataSourceName, "user_name", userName), + ), + }, + }, + }) +} + +func testAccCoralogixDataSourceUser_read() string { + return fmt.Sprintf(`data "coralogix_user" "test" { + id = coralogix_user.test.id + team_id = "%s" +} +`, teamID) +} diff --git a/coralogix/provider.go b/coralogix/provider.go index 6c357a2d..5195f477 100644 --- a/coralogix/provider.go +++ b/coralogix/provider.go @@ -336,6 +336,8 @@ func (p *coralogixProvider) DataSources(context.Context) []func() datasource.Dat NewSLODataSource, NewDashboardsFoldersDataSource, NewApiKeyDataSource, + NewGroupDataSource, + NewUserDataSource, } } @@ -358,5 +360,7 @@ func (p *coralogixProvider) Resources(context.Context) []func() resource.Resourc NewMovingQuotaResource, NewSLOResource, NewDashboardsFolderResource, + NewGroupResource, + NewUserResource, } } diff --git a/coralogix/resource_coralogix_api_key_test.go b/coralogix/resource_coralogix_api_key_test.go index 6d565ea1..7dc4170e 100644 --- a/coralogix/resource_coralogix_api_key_test.go +++ b/coralogix/resource_coralogix_api_key_test.go @@ -9,7 +9,6 @@ import ( var ( apiKeyResourceName = "coralogix_api_key.test" - targetTeam = "4013254" ) func TestApiKeyResource(t *testing.T) { @@ -21,7 +20,7 @@ func TestApiKeyResource(t *testing.T) { Config: testApiKeyResource(), Check: resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttr(apiKeyResourceName, "name", "Test Key 3"), - resource.TestCheckResourceAttr(apiKeyResourceName, "owner.team_id", targetTeam), + resource.TestCheckResourceAttr(apiKeyResourceName, "owner.team_id", teamID), resource.TestCheckResourceAttr(apiKeyResourceName, "active", "true"), resource.TestCheckResourceAttr(apiKeyResourceName, "hashed", "false"), ), @@ -35,7 +34,7 @@ func TestApiKeyResource(t *testing.T) { Config: updateApiKeyResource(), Check: resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttr(apiKeyResourceName, "name", "Test Key 5"), - resource.TestCheckResourceAttr(apiKeyResourceName, "owner.team_id", targetTeam), + resource.TestCheckResourceAttr(apiKeyResourceName, "owner.team_id", teamID), resource.TestCheckResourceAttr(apiKeyResourceName, "active", "false"), resource.TestCheckResourceAttr(apiKeyResourceName, "hashed", "false"), ), @@ -54,7 +53,7 @@ func testApiKeyResource() string { hashed = false roles = ["SCIM"] } -`, "", targetTeam, 1) +`, "", teamID, 1) } func updateApiKeyResource() string { @@ -67,5 +66,5 @@ func updateApiKeyResource() string { hashed = false roles = ["SCIM"] } -`, "", targetTeam, 1) +`, "", teamID, 1) } diff --git a/coralogix/resource_coralogix_group.go b/coralogix/resource_coralogix_group.go new file mode 100644 index 00000000..c1e301ee --- /dev/null +++ b/coralogix/resource_coralogix_group.go @@ -0,0 +1,354 @@ +package coralogix + +import ( + "context" + "encoding/json" + "fmt" + "log" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "terraform-provider-coralogix/coralogix/clientset" + + "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" + "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" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func NewGroupResource() resource.Resource { + return &GroupResource{} +} + +type GroupResource struct { + client *clientset.GroupsClient +} + +func (r *GroupResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_group" +} + +func (r *GroupResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + clientSet, ok := req.ProviderData.(*clientset.ClientSet) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *clientset.ClientSet, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + r.client = clientSet.Groups() +} + +func (r *GroupResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Version: 0, + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + MarkdownDescription: "Group ID.", + }, + "team_id": schema.StringAttribute{ + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "display_name": schema.StringAttribute{ + Required: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + MarkdownDescription: "Group display name.", + }, + "members": schema.SetAttribute{ + Optional: true, + ElementType: types.StringType, + }, + "role": schema.StringAttribute{ + Required: true, + }, + }, + MarkdownDescription: "Coralogix group.", + } +} + +func (r *GroupResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + ids := strings.Split(req.ID, ",") + + if len(ids) != 2 || ids[0] == "" || ids[1] == "" { + resp.Diagnostics.AddError( + "Unexpected Import Identifier", + fmt.Sprintf("Expected import identifier with format: team-id,group-id. Got: %q", req.ID), + ) + return + } + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("team_id"), ids[0])...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), ids[1])...) +} + +func (r *GroupResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan *GroupResourceModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + createGroupRequest, diags := extractCreateGroup(ctx, plan) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + groupStr, _ := json.Marshal(createGroupRequest) + log.Printf("[INFO] Creating new group: %s", string(groupStr)) + teamID := plan.TeamID.ValueString() + createResp, err := r.client.CreateGroup(ctx, teamID, createGroupRequest) + if err != nil { + log.Printf("[ERROR] Received error: %s", err.Error()) + resp.Diagnostics.AddError( + "Error creating Group", + formatRpcErrors(err, r.client.TargetUrl, string(groupStr)), + ) + return + } + groupStr, _ = json.Marshal(createResp) + log.Printf("[INFO] Submitted new group: %s", groupStr) + + state, diags := flattenSCIMGroup(createResp) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + state.TeamID = types.StringValue(teamID) + + // Set state to fully populated data + diags = resp.State.Set(ctx, state) + resp.Diagnostics.Append(diags...) +} + +func flattenSCIMGroup(group *clientset.SCIMGroup) (*GroupResourceModel, diag.Diagnostics) { + members, diags := flattenSCIMGroupMembers(group.Members) + if diags.HasError() { + return nil, diags + } + + return &GroupResourceModel{ + ID: types.StringValue(group.ID), + DisplayName: types.StringValue(group.DisplayName), + Members: members, + Role: types.StringValue(group.Role), + }, nil +} + +func flattenSCIMGroupMembers(members []clientset.SCIMGroupMember) (types.Set, diag.Diagnostics) { + if len(members) == 0 { + return types.SetNull(types.StringType), nil + } + var diags diag.Diagnostics + membersIDs := make([]attr.Value, 0, len(members)) + for _, member := range members { + membersIDs = append(membersIDs, types.StringValue(member.Value)) + } + if diags.HasError() { + return types.SetNull(types.StringType), diags + } + + return types.SetValue(types.StringType, membersIDs) +} + +func (r *GroupResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state *GroupResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + //Get refreshed Group value from Coralogix + id := state.ID.ValueString() + log.Printf("[INFO] Reading Group: %s", id) + teamID := state.TeamID.ValueString() + getGroupResp, err := r.client.GetGroup(ctx, teamID, id) + if err != nil { + log.Printf("[ERROR] Received error: %#v", err) + if status.Code(err) == codes.NotFound { + state.ID = types.StringNull() + resp.Diagnostics.AddWarning( + fmt.Sprintf("Group %q is in state, but no longer exists in Coralogix backend", id), + fmt.Sprintf("%s will be recreated when you apply", id), + ) + } else { + resp.Diagnostics.AddError( + "Error reading Group", + formatRpcErrors(err, fmt.Sprintf("%s/%s", r.client.TargetUrl, id), ""), + ) + } + return + } + respStr, _ := json.Marshal(getGroupResp) + log.Printf("[INFO] Received Group: %s", string(respStr)) + + state, diags = flattenSCIMGroup(getGroupResp) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + state.TeamID = types.StringValue(teamID) + + // Set state to fully populated data + diags = resp.State.Set(ctx, &state) + resp.Diagnostics.Append(diags...) +} + +func (r *GroupResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + // Retrieve values from plan + var plan *GroupResourceModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + groupUpdateReq, diags := extractCreateGroup(ctx, plan) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + groupStr, _ := json.Marshal(groupUpdateReq) + log.Printf("[INFO] Updating Group: %s", string(groupStr)) + teamID := plan.TeamID.ValueString() + groupID := plan.ID.ValueString() + groupUpdateResp, err := r.client.UpdateGroup(ctx, teamID, groupID, groupUpdateReq) + if err != nil { + log.Printf("[ERROR] Received error: %s", err.Error()) + resp.Diagnostics.AddError( + "Error updating Group", + formatRpcErrors(err, fmt.Sprintf("%s/%s", r.client.TargetUrl, groupUpdateReq.ID), string(groupStr)), + ) + return + } + groupStr, _ = json.Marshal(groupUpdateResp) + log.Printf("[INFO] Submitted updated Group: %s", string(groupStr)) + + // Get refreshed Group value from Coralogix + id := plan.ID.ValueString() + getGroupResp, err := r.client.GetGroup(ctx, teamID, id) + if err != nil { + log.Printf("[ERROR] Received error: %s", err.Error()) + if status.Code(err) == codes.NotFound { + plan.ID = types.StringNull() + resp.Diagnostics.AddWarning( + fmt.Sprintf("Group %q is in state, but no longer exists in Coralogix backend", id), + fmt.Sprintf("%s will be recreated when you apply", id), + ) + } else { + resp.Diagnostics.AddError( + "Error reading Group", + formatRpcErrors(err, fmt.Sprintf("%s/%s", r.client.TargetUrl, id), string(groupStr)), + ) + } + return + } + groupStr, _ = json.Marshal(getGroupResp) + log.Printf("[INFO] Received Group: %s", string(groupStr)) + + state, diags := flattenSCIMGroup(getGroupResp) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + state.TeamID = types.StringValue(teamID) + + // Set state to fully populated data + diags = resp.State.Set(ctx, plan) + resp.Diagnostics.Append(diags...) +} + +func (r *GroupResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state *GroupResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + id := state.ID.ValueString() + log.Printf("[INFO] Deleting Group %s", id) + teamID := state.TeamID.ValueString() + if err := r.client.DeleteGroup(ctx, teamID, id); err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("Error Deleting Group %s", id), + formatRpcErrors(err, fmt.Sprintf("%s/%s", r.client.TargetUrl, id), ""), + ) + return + } + log.Printf("[INFO] Group %s deleted", id) +} + +type GroupResourceModel struct { + ID types.String `tfsdk:"id"` + TeamID types.String `tfsdk:"team_id"` + DisplayName types.String `tfsdk:"display_name"` + Members types.Set `tfsdk:"members"` // Set of strings + Role types.String `tfsdk:"role"` +} + +func extractCreateGroup(ctx context.Context, plan *GroupResourceModel) (*clientset.SCIMGroup, diag.Diagnostics) { + members, diags := extractGroupMembers(ctx, plan.Members) + if diags.HasError() { + return nil, diags + } + + var id *string + if !plan.ID.IsNull() || plan.ID.IsUnknown() { + id = new(string) + *id = plan.ID.ValueString() + } + return &clientset.SCIMGroup{ + DisplayName: plan.DisplayName.ValueString(), + Members: members, + Role: plan.Role.ValueString(), + }, nil +} + +func extractGroupMembers(ctx context.Context, members types.Set) ([]clientset.SCIMGroupMember, diag.Diagnostics) { + membersElements := members.Elements() + groupMembers := make([]clientset.SCIMGroupMember, 0, len(membersElements)) + var diags diag.Diagnostics + for _, member := range membersElements { + val, err := member.ToTerraformValue(ctx) + if err != nil { + diags.AddError("Failed to convert value to Terraform", err.Error()) + continue + } + var str string + + if err = val.As(&str); err != nil { + diags.AddError("Failed to convert value to string", err.Error()) + continue + } + groupMembers = append(groupMembers, clientset.SCIMGroupMember{Value: str}) + } + if diags.HasError() { + return nil, diags + } + return groupMembers, nil +} diff --git a/coralogix/resource_coralogix_group_test.go b/coralogix/resource_coralogix_group_test.go new file mode 100644 index 00000000..9a1c64ac --- /dev/null +++ b/coralogix/resource_coralogix_group_test.go @@ -0,0 +1,78 @@ +package coralogix + +import ( + "context" + "fmt" + "testing" + + "terraform-provider-coralogix/coralogix/clientset" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" +) + +var groupResourceName = "coralogix_group.test" + +func TestAccCoralogixResourceGroup(t *testing.T) { + userName := randUserName() + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckGroupDestroy, + Steps: []resource.TestStep{ + { + Config: testAccCoralogixResourceGroup(userName), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet(groupResourceName, "id"), + resource.TestCheckResourceAttr(groupResourceName, "display_name", "example"), + resource.TestCheckResourceAttr(groupResourceName, "role", "Read Only"), + resource.TestCheckResourceAttr(groupResourceName, "members.#", "1"), + resource.TestCheckResourceAttrPair(groupResourceName, "members.0", "coralogix_user.test", "id"), + ), + }, + { + ResourceName: groupResourceName, + ImportState: true, + ImportStateIdPrefix: teamID + ",", // teamID is the prefix for the user ID + ImportStateVerify: true, + }, + }, + }) +} + +func testAccCheckGroupDestroy(s *terraform.State) error { + client := testAccProvider.Meta().(*clientset.ClientSet).Groups() + + ctx := context.TODO() + + for _, rs := range s.RootModule().Resources { + if rs.Type != "coralogix_group" { + continue + } + + resp, err := client.GetGroup(ctx, teamID, rs.Primary.ID) + if err == nil { + if resp.ID == rs.Primary.ID { + return fmt.Errorf("group still exists: %s", rs.Primary.ID) + } + } + } + + return nil +} + +func testAccCoralogixResourceGroup(userName string) string { + return fmt.Sprintf(` + resource "coralogix_user" "test" { + team_id = "%[1]s" + user_name = "%[2]s" + } + + resource "coralogix_group" "test" { + team_id = "%[1]s" + display_name = "example" + role = "Read Only" + members = [coralogix_user.test.id] + } +`, teamID, userName) +} diff --git a/coralogix/resource_coralogix_user.go b/coralogix/resource_coralogix_user.go new file mode 100644 index 00000000..8dcbacc8 --- /dev/null +++ b/coralogix/resource_coralogix_user.go @@ -0,0 +1,519 @@ +package coralogix + +import ( + "context" + "encoding/json" + "fmt" + "log" + "strings" + + "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" + "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/boolplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/objectplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/setplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "terraform-provider-coralogix/coralogix/clientset" +) + +func NewUserResource() resource.Resource { + return &UserResource{} +} + +type UserResource struct { + client *clientset.UsersClient +} + +func (r *UserResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_user" +} + +func (r *UserResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + clientSet, ok := req.ProviderData.(*clientset.ClientSet) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *clientset.ClientSet, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + r.client = clientSet.Users() +} + +func (r *UserResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Version: 0, + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + MarkdownDescription: "User ID.", + }, + "team_id": schema.StringAttribute{ + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + MarkdownDescription: "The ID of team that the user will be created. If this value is changed, the user will be deleted and recreate.", + }, + "user_name": schema.StringAttribute{ + Required: true, + MarkdownDescription: "User name.", + }, + "name": schema.SingleNestedAttribute{ + Optional: true, + Computed: true, + Attributes: map[string]schema.Attribute{ + "given_name": schema.StringAttribute{ + Optional: true, + }, + "family_name": schema.StringAttribute{ + Optional: true, + }, + }, + PlanModifiers: []planmodifier.Object{ + objectplanmodifier.UseStateForUnknown(), + }, + }, + "active": schema.BoolAttribute{ + Optional: true, + Computed: true, + Default: booldefault.StaticBool(true), + }, + "emails": schema.SetNestedAttribute{ + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "primary": schema.BoolAttribute{ + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "value": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "type": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, + PlanModifiers: []planmodifier.Object{ + objectplanmodifier.UseStateForUnknown(), + }, + }, + PlanModifiers: []planmodifier.Set{ + setplanmodifier.UseStateForUnknown(), + }, + }, + "groups": schema.SetAttribute{ + Computed: true, + ElementType: types.StringType, + PlanModifiers: []planmodifier.Set{ + setplanmodifier.UseStateForUnknown(), + }, + }, + }, + MarkdownDescription: "Coralogix User.", + } +} + +func (r *UserResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + ids := strings.Split(req.ID, ",") + + if len(ids) != 2 || ids[0] == "" || ids[1] == "" { + resp.Diagnostics.AddError( + "Unexpected Import Identifier", + fmt.Sprintf("Expected import identifier with format: team-id,user-id. Got: %q", req.ID), + ) + return + } + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("team_id"), ids[0])...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), ids[1])...) +} + +func (r *UserResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan *UserResourceModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + createUserRequest, diags := extractCreateUser(ctx, plan) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + userStr, _ := json.Marshal(createUserRequest) + log.Printf("[INFO] Creating new User: %s", string(userStr)) + teamID := plan.TeamID.ValueString() + log.Printf("[INFO] Team ID: %s", teamID) + createResp, err := r.client.CreateUser(ctx, teamID, createUserRequest) + if err != nil { + log.Printf("[ERROR] Received error: %s", err.Error()) + resp.Diagnostics.AddError( + "Error creating User", + formatRpcErrors(err, r.client.TargetUrl, string(userStr)), + ) + return + } + userStr, _ = json.Marshal(createResp) + log.Printf("[INFO] Submitted new User: %s", userStr) + + state, diags := flattenSCIMUser(ctx, createResp) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + state.TeamID = types.StringValue(teamID) + + // Set state to fully populated data + diags = resp.State.Set(ctx, state) + resp.Diagnostics.Append(diags...) +} + +func flattenSCIMUser(ctx context.Context, user *clientset.SCIMUser) (*UserResourceModel, diag.Diagnostics) { + name, diags := flattenSCIMUserName(ctx, user.Name) + if diags.HasError() { + return nil, diags + } + + emails, diags := flattenSCIMUserEmails(ctx, user.Emails) + if diags.HasError() { + return nil, diags + } + + groups, diags := flattenSCIMUserGroups(ctx, user.Groups) + if diags.HasError() { + return nil, diags + } + + return &UserResourceModel{ + ID: types.StringValue(*user.ID), + UserName: types.StringValue(user.UserName), + Name: name, + Active: types.BoolValue(user.Active), + Emails: emails, + Groups: groups, + }, nil +} + +func flattenSCIMUserEmails(ctx context.Context, emails []clientset.SCIMUserEmail) (types.Set, diag.Diagnostics) { + emailsIDs := make([]UserEmailModel, 0, len(emails)) + for _, email := range emails { + emailModel := UserEmailModel{ + Primary: types.BoolValue(email.Primary), + Value: types.StringValue(email.Value), + Type: types.StringValue(email.Type), + } + emailsIDs = append(emailsIDs, emailModel) + } + return types.SetValueFrom(ctx, types.ObjectType{AttrTypes: SCIMUserEmailAttr()}, emailsIDs) +} + +func SCIMUserEmailAttr() map[string]attr.Type { + return map[string]attr.Type{ + "primary": types.BoolType, + "value": types.StringType, + "type": types.StringType, + } +} + +func flattenSCIMUserName(ctx context.Context, name *clientset.SCIMUserName) (types.Object, diag.Diagnostics) { + if name == nil { + return types.ObjectNull(sCIMUserNameAttr()), nil + } + return types.ObjectValueFrom(ctx, sCIMUserNameAttr(), &UserNameModel{ + GivenName: types.StringValue(name.GivenName), + FamilyName: types.StringValue(name.FamilyName), + }) +} + +func sCIMUserNameAttr() map[string]attr.Type { + return map[string]attr.Type{ + "given_name": types.StringType, + "family_name": types.StringType, + } +} + +func flattenSCIMUserGroups(ctx context.Context, groups []clientset.SCIMUserGroup) (types.Set, diag.Diagnostics) { + groupsIDs := make([]string, 0, len(groups)) + for _, group := range groups { + groupsIDs = append(groupsIDs, group.Value) + } + return types.SetValueFrom(ctx, types.StringType, groupsIDs) +} + +func (r *UserResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state *UserResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + //Get refreshed User value from Coralogix + id := state.ID.ValueString() + teamID := state.TeamID.ValueString() + getUserResp, err := r.client.GetUser(ctx, teamID, id) + if err != nil { + log.Printf("[ERROR] Received error: %#v", err) + if status.Code(err) == codes.NotFound { + state.ID = types.StringNull() + resp.Diagnostics.AddWarning( + fmt.Sprintf("User %q is in state, but no longer exists in Coralogix backend", id), + fmt.Sprintf("%s will be recreated when you apply", id), + ) + } else { + resp.Diagnostics.AddError( + "Error reading User", + formatRpcErrors(err, fmt.Sprintf("%s/%s", r.client.TargetUrl, id), ""), + ) + } + return + } + respStr, _ := json.Marshal(getUserResp) + log.Printf("[INFO] Received User: %s", string(respStr)) + + teamID = state.TeamID.ValueString() + state, diags = flattenSCIMUser(ctx, getUserResp) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + state.TeamID = types.StringValue(teamID) + + // Set state to fully populated data + diags = resp.State.Set(ctx, &state) + resp.Diagnostics.Append(diags...) +} + +func (r *UserResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + // Retrieve values from plan + var plan, state *UserResourceModel + diags := req.Plan.Get(ctx, &plan) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + diags = req.State.Get(ctx, &state) + if resp.Diagnostics.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + if plan.UserName.ValueString() != state.UserName.ValueString() { + resp.Diagnostics.AddError( + "User name cannot be updated", + "User name is immutable and cannot be updated", + ) + return + } + + userUpdateReq, diags := extractCreateUser(ctx, plan) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + userStr, _ := json.Marshal(userUpdateReq) + log.Printf("[INFO] Updating User: %s", string(userStr)) + teamID := plan.TeamID.ValueString() + userID := plan.ID.ValueString() + userUpdateResp, err := r.client.UpdateUser(ctx, teamID, userID, userUpdateReq) + if err != nil { + log.Printf("[ERROR] Received error: %s", err.Error()) + resp.Diagnostics.AddError( + "Error updating User", + formatRpcErrors(err, fmt.Sprintf("%s/%s", r.client.TargetUrl, userID), string(userStr)), + ) + return + } + userStr, _ = json.Marshal(userUpdateResp) + log.Printf("[INFO] Submitted updated User: %s", string(userStr)) + + // Get refreshed User value from Coralogix + id := plan.ID.ValueString() + getUserResp, err := r.client.GetUser(ctx, teamID, id) + if err != nil { + log.Printf("[ERROR] Received error: %s", err.Error()) + if status.Code(err) == codes.NotFound { + plan.ID = types.StringNull() + resp.Diagnostics.AddWarning( + fmt.Sprintf("User %q is in state, but no longer exists in Coralogix backend", id), + fmt.Sprintf("%s will be recreated when you apply", id), + ) + } else { + resp.Diagnostics.AddError( + "Error reading User", + formatRpcErrors(err, fmt.Sprintf("%s/%s", r.client.TargetUrl, id), string(userStr)), + ) + } + return + } + userStr, _ = json.Marshal(getUserResp) + log.Printf("[INFO] Received User: %s", string(userStr)) + + state, diags = flattenSCIMUser(ctx, getUserResp) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + state.TeamID = types.StringValue(teamID) + + // Set state to fully populated data + diags = resp.State.Set(ctx, state) + resp.Diagnostics.Append(diags...) +} + +func (r *UserResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state *UserResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + id := state.ID.ValueString() + log.Printf("[INFO] Deleting User %s", id) + teamID := state.TeamID.ValueString() + if err := r.client.DeleteUser(ctx, teamID, id); err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("Error Deleting User %s", id), + formatRpcErrors(err, fmt.Sprintf("%s/%s", r.client.TargetUrl, id), ""), + ) + return + } + log.Printf("[INFO] User %s deleted", id) +} + +type UserResourceModel struct { + ID types.String `tfsdk:"id"` + TeamID types.String `tfsdk:"team_id"` + UserName types.String `tfsdk:"user_name"` + Name types.Object `tfsdk:"name"` //UserNameModel + Active types.Bool `tfsdk:"active"` + Emails types.Set `tfsdk:"emails"` //UserEmailModel + Groups types.Set `tfsdk:"groups"` //types.String +} + +type UserNameModel struct { + GivenName types.String `tfsdk:"given_name"` + FamilyName types.String `tfsdk:"family_name"` +} + +type UserEmailModel struct { + Primary types.Bool `tfsdk:"primary"` + Value types.String `tfsdk:"value"` + Type types.String `tfsdk:"type"` +} + +func extractCreateUser(ctx context.Context, plan *UserResourceModel) (*clientset.SCIMUser, diag.Diagnostics) { + name, diags := extractUserSCIMName(ctx, plan.Name) + if diags.HasError() { + return nil, diags + } + emails, diags := extractUserEmails(ctx, plan.Emails) + if diags.HasError() { + return nil, diags + } + groups, diags := extractUserGroups(ctx, plan.Groups) + if diags.HasError() { + return nil, diags + } + + return &clientset.SCIMUser{ + Schemas: []string{}, + UserName: plan.UserName.ValueString(), + Name: name, + Active: plan.Active.ValueBool(), + Emails: emails, + Groups: groups, + }, nil +} + +func extractUserGroups(ctx context.Context, groups types.Set) ([]clientset.SCIMUserGroup, diag.Diagnostics) { + groupsElements := groups.Elements() + userGroups := make([]clientset.SCIMUserGroup, 0, len(groupsElements)) + var diags diag.Diagnostics + for _, group := range groupsElements { + val, err := group.ToTerraformValue(ctx) + if err != nil { + diags.AddError("Failed to convert value to Terraform", err.Error()) + continue + } + + var str string + if err = val.As(&str); err != nil { + diags.AddError("Failed to convert value to string", err.Error()) + continue + } + userGroups = append(userGroups, clientset.SCIMUserGroup{Value: str}) + } + if diags.HasError() { + return nil, diags + } + return userGroups, nil +} + +func extractUserSCIMName(ctx context.Context, name types.Object) (*clientset.SCIMUserName, diag.Diagnostics) { + if name.IsNull() || name.IsUnknown() { + return nil, nil + } + var nameModel UserNameModel + diags := name.As(ctx, &nameModel, basetypes.ObjectAsOptions{}) + if diags.HasError() { + return nil, diags + } + + return &clientset.SCIMUserName{ + GivenName: nameModel.GivenName.ValueString(), + FamilyName: nameModel.FamilyName.ValueString(), + }, nil +} + +func extractUserEmails(ctx context.Context, emails types.Set) ([]clientset.SCIMUserEmail, diag.Diagnostics) { + var diags diag.Diagnostics + var emailsObjects []types.Object + var expandedEmails []clientset.SCIMUserEmail + emails.ElementsAs(ctx, &emailsObjects, true) + + for _, eo := range emailsObjects { + var email UserEmailModel + if dg := eo.As(ctx, &email, basetypes.ObjectAsOptions{}); dg.HasError() { + diags.Append(dg...) + continue + } + expandedEmail := clientset.SCIMUserEmail{ + Value: email.Value.ValueString(), + Primary: email.Primary.ValueBool(), + Type: email.Type.ValueString(), + } + expandedEmails = append(expandedEmails, expandedEmail) + } + + return expandedEmails, diags +} diff --git a/coralogix/resource_coralogix_user_test.go b/coralogix/resource_coralogix_user_test.go new file mode 100644 index 00000000..e4176c72 --- /dev/null +++ b/coralogix/resource_coralogix_user_test.go @@ -0,0 +1,81 @@ +package coralogix + +import ( + "context" + "fmt" + "os" + "testing" + + "terraform-provider-coralogix/coralogix/clientset" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" +) + +var userResourceName = "coralogix_user.test" +var teamID = os.Getenv("TEST_TEAM_ID") + +func TestAccCoralogixResourceUser(t *testing.T) { + userName := randUserName() + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckUserDestroy, + Steps: []resource.TestStep{ + { + Config: testAccCoralogixResourceUser(userName), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet(userResourceName, "id"), + resource.TestCheckResourceAttr(userResourceName, "user_name", userName), + resource.TestCheckResourceAttr(userResourceName, "name.given_name", "Test"), + resource.TestCheckResourceAttr(userResourceName, "name.family_name", "User"), + ), + }, + { + ResourceName: userResourceName, + ImportState: true, + ImportStateIdPrefix: teamID + ",", // teamID is the prefix for the user ID + ImportStateVerify: true, + }, + }, + }) +} + +func testAccCheckUserDestroy(s *terraform.State) error { + client := testAccProvider.Meta().(*clientset.ClientSet).Users() + + ctx := context.TODO() + + for _, rs := range s.RootModule().Resources { + if rs.Type != "coralogix_user" { + continue + } + + resp, err := client.GetUser(ctx, teamID, rs.Primary.ID) + if err == nil && resp != nil { + if *resp.ID == rs.Primary.ID && resp.Active { + return fmt.Errorf("user still exists and active: %s", rs.Primary.ID) + } + } + } + + return nil +} + +func randUserName() string { + return "test@coralogix.com" + //return fmt.Sprintf("%s@coralogix.com", RandStringBytes(5)) +} + +func testAccCoralogixResourceUser(userName string) string { + return fmt.Sprintf(` + resource "coralogix_user" "test" { + team_id = "%s" + user_name = "%s" + name = { + given_name = "Test" + family_name = "User" + } + } +`, teamID, userName) +} diff --git a/coralogix/utils.go b/coralogix/utils.go index cd330bf5..a22f9860 100644 --- a/coralogix/utils.go +++ b/coralogix/utils.go @@ -111,6 +111,31 @@ func frameworkDatasourceSchemaFromFrameworkResourceSchema(rs resourceschema.Sche } } +func frameworkDatasourceSchemaFromFrameworkResourceSchemaWithTeamID(rs resourceschema.Schema) datasourceschema.Schema { + attributes := convertAttributes(rs.Attributes) + if idSchema, ok := rs.Attributes["id"]; ok { + attributes["id"] = datasourceschema.StringAttribute{ + Required: true, + Description: idSchema.GetDescription(), + MarkdownDescription: idSchema.GetMarkdownDescription(), + } + } + teamID := rs.Attributes["team_id"] + attributes["team_id"] = datasourceschema.StringAttribute{ + Required: true, + Description: teamID.GetDescription(), + MarkdownDescription: teamID.GetMarkdownDescription(), + } + + return datasourceschema.Schema{ + Attributes: attributes, + //Blocks: convertBlocks(rs.Blocks), + Description: rs.Description, + MarkdownDescription: rs.MarkdownDescription, + DeprecationMessage: rs.DeprecationMessage, + } +} + func convertAttributes(attributes map[string]resourceschema.Attribute) map[string]datasourceschema.Attribute { result := make(map[string]datasourceschema.Attribute, len(attributes)) for k, v := range attributes { diff --git a/docs/data-sources/dashboards_folder.md b/docs/data-sources/dashboards_folder.md new file mode 100644 index 00000000..53750e76 --- /dev/null +++ b/docs/data-sources/dashboards_folder.md @@ -0,0 +1,24 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "coralogix_dashboards_folder Data Source - terraform-provider-coralogix" +subcategory: "" +description: |- + +--- + +# coralogix_dashboards_folder (Data Source) + + + + + + +## Schema + +### Required + +- `id` (String) Unique identifier for the folder. + +### Read-Only + +- `name` (String) Display name of the folder. diff --git a/docs/data-sources/group.md b/docs/data-sources/group.md new file mode 100644 index 00000000..5cb48af6 --- /dev/null +++ b/docs/data-sources/group.md @@ -0,0 +1,35 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "coralogix_group Data Source - terraform-provider-coralogix" +subcategory: "" +description: |- + Coralogix group. +--- + +# coralogix_group (Data Source) + +Coralogix group. + +## Example Usage + +```hcl +data "coralogix_group" "example" { + id = "group-id" + team_id = "team-id" +} +``` + + + +## Schema + +### Required + +- `id` (String) Group ID. +- `team_id` (String) + +### Read-Only + +- `display_name` (String) Group display name. +- `members` (Set of String) +- `role` (String) diff --git a/docs/data-sources/user.md b/docs/data-sources/user.md new file mode 100644 index 00000000..e60c3be5 --- /dev/null +++ b/docs/data-sources/user.md @@ -0,0 +1,54 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "coralogix_user Data Source - terraform-provider-coralogix" +subcategory: "" +description: |- + Coralogix User. +--- + +# coralogix_user (Data Source) + +Coralogix User. + +## Example Usage + +```hcl +data "coralogix_user" "example" { + id = "user-id" + team_id = "team-id" +} +``` + + +## Schema + +### Required + +- `id` (String) User ID. +- `team_id` (String) The ID of team that the user will be created. If this value is changed, the user will be deleted and recreate. + +### Read-Only + +- `active` (Boolean) +- `emails` (Attributes Set) (see [below for nested schema](#nestedatt--emails)) +- `groups` (Set of String) +- `name` (Attributes) (see [below for nested schema](#nestedatt--name)) +- `user_name` (String) User name. + + +### Nested Schema for `emails` + +Read-Only: + +- `primary` (Boolean) +- `type` (String) +- `value` (String) + + + +### Nested Schema for `name` + +Read-Only: + +- `family_name` (String) +- `given_name` (String) diff --git a/docs/resources/group.md b/docs/resources/group.md new file mode 100644 index 00000000..384ced2d --- /dev/null +++ b/docs/resources/group.md @@ -0,0 +1,37 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "coralogix_group Resource - terraform-provider-coralogix" +subcategory: "" +description: |- + Coralogix group. +--- + +# coralogix_group (Resource) + +Coralogix group. + + + + +## Schema + +### Required + +- `display_name` (String) Group display name. +- `members` (Set of String) +- `role` (String) +- `team_id` (String) + +### Read-Only + +- `id` (String) Group ID. + +### Import +```sh +terraform import coralogix_group.example ",," +``` +**Note**: The group id and the team id are required for importing the group. + +For getting the group id, send a GET request to the following endpoint `https://ng-api-http./scim/Groups` with cgx-team-id header and the org-key as the Bearer Token, and check for the id of the desired group. + +[region-domain table](../index.md#region-domain-table) \ No newline at end of file diff --git a/docs/resources/user.md b/docs/resources/user.md new file mode 100644 index 00000000..f5e7e50d --- /dev/null +++ b/docs/resources/user.md @@ -0,0 +1,75 @@ +--- + +# generated by https://github.com/hashicorp/terraform-plugin-docs + +page_title: "coralogix_user Resource - terraform-provider-coralogix" +subcategory: "" +description: |- +Coralogix User. +--- + +# coralogix_user (Resource) + +Coralogix User. + +## Example Usage + +```hcl +resource "coralogix_user" "example" { + team_id = "team-id" + user_name = "user-name" + name { + family_name = "family-name" + given_name = "given-name" + } +} +``` + + + +## Schema + +### Required + +- `team_id` (String, Sensitive) +- `user_name` (String) User name. + +### Optional + +- `active` (Boolean) +- `emails` (Attributes Set) (see [below for nested schema](#nestedatt--emails)) +- `groups` (Set of String) +- `name` (Attributes) (see [below for nested schema](#nestedatt--name)) + +### Read-Only + +- `id` (String) User ID. + + + +### Nested Schema for `emails` + +Required: + +- `primary` (Boolean) +- `type` (String) +- `value` (String) + + + +### Nested Schema for `name` + +Optional: + +- `family_name` (String) +- `given_name` (String) + +### Import +```sh +terraform import coralogix_user.example ",," +``` +**Note**: The user id and the team id are required for importing the user. + +For getting the user id, send a GET request to the following endpoint `https://ng-api-http./scim/Users` with cgx-team-id header and the org-key as the Bearer Token, and check for the id of the desired user. + +[region-domain table](../index.md#region-domain-table) \ No newline at end of file diff --git a/examples/group/main.tf b/examples/group/main.tf new file mode 100644 index 00000000..1616548f --- /dev/null +++ b/examples/group/main.tf @@ -0,0 +1,26 @@ +terraform { + required_providers { + coralogix = { + version = "~> 1.8" + source = "coralogix/coralogix" + } + } +} + +provider "coralogix" { + #api_key = "" + #env = "" +} + +resource "coralogix_group" "example" { + team_id = "team-id" + display_name = "example" + role = "Read Only" + members = [coralogix_user.example.id] +} + +resource "coralogix_user" "example" { + team_id = "team-id" + user_name = "example@coralogix.com" +} + diff --git a/examples/user/main.tf b/examples/user/main.tf new file mode 100644 index 00000000..cce06b7e --- /dev/null +++ b/examples/user/main.tf @@ -0,0 +1,30 @@ +terraform { + required_providers { + coralogix = { + version = "~> 1.8" + source = "coralogix/coralogix" + } + } +} + +provider "coralogix" { + #api_key = "" + #env = "" +} + +resource "coralogix_user" "example" { + user_name = "example@coralogix.com" + team_id = "team_id" + name = { + given_name = "example" + family_name = "example" + } +} + +resource "coralogix_group" "example" { + team_id = "team_id" + display_name = "example" + role = "Read Only" + members = [coralogix_user.example.id] +} +