diff --git a/castai/provider.go b/castai/provider.go index 20a05fec..4958aa45 100644 --- a/castai/provider.go +++ b/castai/provider.go @@ -53,6 +53,7 @@ func Provider(version string) *schema.Provider { "castai_organization_members": resourceOrganizationMembers(), "castai_sso_connection": resourceSSOConnection(), "castai_workload_scaling_policy": resourceWorkloadScalingPolicy(), + "castai_organization_group": resourceOrganizationGroup(), "castai_role_bindings": resourceRoleBindings(), }, diff --git a/castai/resource_node_configuration.go b/castai/resource_node_configuration.go index 5a4a45ba..d339add8 100644 --- a/castai/resource_node_configuration.go +++ b/castai/resource_node_configuration.go @@ -21,26 +21,28 @@ import ( ) const ( - FieldNodeConfigurationName = "name" - FieldNodeConfigurationDiskCpuRatio = "disk_cpu_ratio" - FieldNodeConfigurationMinDiskSize = "min_disk_size" - FieldNodeConfigurationDrainTimeoutSec = "drain_timeout_sec" - FieldNodeConfigurationSubnets = "subnets" - FieldNodeConfigurationSSHPublicKey = "ssh_public_key" - FieldNodeConfigurationImage = "image" - FieldNodeConfigurationTags = "tags" - FieldNodeConfigurationInitScript = "init_script" - FieldNodeConfigurationContainerRuntime = "container_runtime" - FieldNodeConfigurationDockerConfig = "docker_config" - FieldNodeConfigurationKubeletConfig = "kubelet_config" - FieldNodeConfigurationAKS = "aks" - FieldNodeConfigurationEKS = "eks" - FieldNodeConfigurationKOPS = "kops" - FieldNodeConfigurationGKE = "gke" - FieldNodeConfigurationEKSTargetGroup = "target_group" - FieldNodeConfigurationAKSImageFamily = "aks_image_family" - FieldNodeConfigurationEKSImageFamily = "eks_image_family" - FieldNodeConfigurationLoadbalancers = "loadbalancers" + FieldNodeConfigurationName = "name" + FieldNodeConfigurationDiskCpuRatio = "disk_cpu_ratio" + FieldNodeConfigurationMinDiskSize = "min_disk_size" + FieldNodeConfigurationDrainTimeoutSec = "drain_timeout_sec" + FieldNodeConfigurationSubnets = "subnets" + FieldNodeConfigurationSSHPublicKey = "ssh_public_key" + FieldNodeConfigurationImage = "image" + FieldNodeConfigurationTags = "tags" + FieldNodeConfigurationInitScript = "init_script" + FieldNodeConfigurationContainerRuntime = "container_runtime" + FieldNodeConfigurationDockerConfig = "docker_config" + FieldNodeConfigurationKubeletConfig = "kubelet_config" + FieldNodeConfigurationAKS = "aks" + FieldNodeConfigurationEKS = "eks" + FieldNodeConfigurationKOPS = "kops" + FieldNodeConfigurationGKE = "gke" + FieldNodeConfigurationEKSTargetGroup = "target_group" + FieldNodeConfigurationAKSImageFamily = "aks_image_family" + FieldNodeConfigurationEKSImageFamily = "eks_image_family" + FieldNodeConfigurationLoadbalancers = "loadbalancers" + FieldNodeConfigurationAKSLoadbalancerIPPools = "ip_based_backend_pools" + FieldNodeConfigurationAKSLoadbalancerNICPools = "nic_based_backend_pools" ) const ( @@ -333,15 +335,21 @@ func resourceNodeConfiguration() *schema.Resource { FieldNodeConfigurationLoadbalancers: { Type: schema.TypeList, Optional: true, - Description: "Loadboalancer configuration for CAST provisioned nodes", + Description: "Load balancer configuration for CAST provisioned nodes", Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ + "id": { + Type: schema.TypeString, + Description: "The full ID of the load balancer in azure.", + Optional: true, // Can't make it required as it was added after `name` so it'd be a breaking change. + }, "name": { Type: schema.TypeString, - Required: true, - Description: "Name of loadbalancer", + Description: "Name of load balancer", + Optional: true, + Deprecated: "name field is deprecated, use ID instead. Will be removed in future versions.", }, - "ip_based_backend_pools": { + FieldNodeConfigurationAKSLoadbalancerIPPools: { Type: schema.TypeList, Optional: true, Description: "IP based backend pools configuration for CAST provisioned nodes", @@ -355,6 +363,20 @@ func resourceNodeConfiguration() *schema.Resource { }, }, }, + FieldNodeConfigurationAKSLoadbalancerNICPools: { + Type: schema.TypeList, + Optional: true, + Description: "NIC based backend pools configuration for CAST provisioned nodes.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Required: true, + Description: "Name of the NIC based backend pool", + }, + }, + }, + }, }, }, }, @@ -981,12 +1003,19 @@ func toAksLoadBalancers(obj []interface{}) *[]sdk.NodeconfigV1AKSConfigLoadBalan for _, lbRaw := range obj { if lb, ok := lbRaw.(map[string]interface{}); ok { sdkLB := sdk.NodeconfigV1AKSConfigLoadBalancers{} + if id, ok := lb["id"].(string); ok && id != "" { + sdkLB.Id = lo.ToPtr(id) + } if name, ok := lb["name"].(string); ok && name != "" { + //nolint:staticcheck //We have to do this until we drop the field in TF major provider version. sdkLB.Name = lo.ToPtr(name) } - if ipBasedBackendPools, ok := lb["ip_based_backend_pools"].([]interface{}); ok && len(ipBasedBackendPools) > 0 { + if ipBasedBackendPools, ok := lb[FieldNodeConfigurationAKSLoadbalancerIPPools].([]interface{}); ok && len(ipBasedBackendPools) > 0 { sdkLB.IpBasedBackendPools = toAksIpBasedBackendPools(ipBasedBackendPools) } + if nicBasedBackendPools, ok := lb[FieldNodeConfigurationAKSLoadbalancerNICPools].([]interface{}); ok && len(nicBasedBackendPools) > 0 { + sdkLB.NicBasedBackendPools = toAksNICBasedBackendPools(nicBasedBackendPools) + } out = append(out, sdkLB) } } @@ -999,18 +1028,33 @@ func toAksIpBasedBackendPools(obj []interface{}) *[]sdk.NodeconfigV1AKSConfigLoa return nil } - out := make([]sdk.NodeconfigV1AKSConfigLoadBalancersIPBasedBackendPool, 0, len(obj)) - for _, poolRaw := range obj { + pools := lo.Map(extractAksBackendPoolNames(obj), func(name string, _ int) sdk.NodeconfigV1AKSConfigLoadBalancersIPBasedBackendPool { + return sdk.NodeconfigV1AKSConfigLoadBalancersIPBasedBackendPool{Name: lo.ToPtr(name)} + }) + return &pools +} + +func toAksNICBasedBackendPools(obj []any) *[]sdk.NodeconfigV1AKSConfigLoadBalancersNICBasedBackendPool { + if obj == nil { + return nil + } + + pools := lo.Map(extractAksBackendPoolNames(obj), func(name string, _ int) sdk.NodeconfigV1AKSConfigLoadBalancersNICBasedBackendPool { + return sdk.NodeconfigV1AKSConfigLoadBalancersNICBasedBackendPool{Name: lo.ToPtr(name)} + }) + return &pools +} + +func extractAksBackendPoolNames(pools []any) []string { + return lo.Reduce(pools, func(names []string, poolRaw any, _ int) []string { if pool, ok := poolRaw.(map[string]interface{}); ok { - sdkPool := sdk.NodeconfigV1AKSConfigLoadBalancersIPBasedBackendPool{} if name, ok := pool["name"].(string); ok && name != "" { - sdkPool.Name = lo.ToPtr(name) + names = append(names, name) } - out = append(out, sdkPool) } - } - return &out + return names + }, make([]string, 0)) } func toAKSOSDiskType(v string) *sdk.NodeconfigV1AKSConfigOsDiskType { @@ -1077,11 +1121,29 @@ func fromAksLoadBalancers(lbs []sdk.NodeconfigV1AKSConfigLoadBalancers) []map[st out := make([]map[string]interface{}, 0, len(lbs)) for _, lb := range lbs { m := map[string]interface{}{} + if lb.Id != nil { + m["id"] = *lb.Id + } + //nolint:staticcheck //We have to do this until we drop the field in TF major provider version. if lb.Name != nil { + //nolint:staticcheck //We have to do this until we drop the field in TF major provider version. m["name"] = *lb.Name } if lb.IpBasedBackendPools != nil && len(*lb.IpBasedBackendPools) > 0 { - m["ip_based_backend_pools"] = fromAksIpBasedBackendPools(*lb.IpBasedBackendPools) + m[FieldNodeConfigurationAKSLoadbalancerIPPools] = fromAksIpBasedBackendPoolNames(lo.FilterMap(*lb.IpBasedBackendPools, func(pool sdk.NodeconfigV1AKSConfigLoadBalancersIPBasedBackendPool, _ int) (string, bool) { + if pool.Name != nil { + return *pool.Name, true + } + return "", false + })) + } + if lb.NicBasedBackendPools != nil && len(*lb.NicBasedBackendPools) > 0 { + m[FieldNodeConfigurationAKSLoadbalancerNICPools] = fromAksIpBasedBackendPoolNames(lo.FilterMap(*lb.NicBasedBackendPools, func(pool sdk.NodeconfigV1AKSConfigLoadBalancersNICBasedBackendPool, _ int) (string, bool) { + if pool.Name != nil { + return *pool.Name, true + } + return "", false + })) } out = append(out, m) } @@ -1089,17 +1151,15 @@ func fromAksLoadBalancers(lbs []sdk.NodeconfigV1AKSConfigLoadBalancers) []map[st return out } -func fromAksIpBasedBackendPools(pools []sdk.NodeconfigV1AKSConfigLoadBalancersIPBasedBackendPool) []map[string]interface{} { - if pools == nil { +func fromAksIpBasedBackendPoolNames(names []string) []map[string]interface{} { + if names == nil { return nil } - out := make([]map[string]interface{}, 0, len(pools)) - for _, pool := range pools { + out := make([]map[string]interface{}, 0, len(names)) + for _, name := range names { m := map[string]interface{}{} - if pool.Name != nil { - m["name"] = *pool.Name - } + m["name"] = name out = append(out, m) } diff --git a/castai/resource_organization_group.go b/castai/resource_organization_group.go new file mode 100644 index 00000000..eb02f053 --- /dev/null +++ b/castai/resource_organization_group.go @@ -0,0 +1,304 @@ +package castai + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "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" + "github.com/samber/lo" + + "github.com/castai/terraform-provider-castai/castai/sdk" +) + +const ( + FieldOrganizationGroupOrganizationID = "organization_id" + FieldOrganizationGroupName = "name" + FieldOrganizationGroupDescription = "description" + FieldOrganizationGroupMembers = "members" + FieldOrganizationGroupMember = "member" + FieldOrganizationGroupMemberKind = "kind" + FieldOrganizationGroupMemberID = "id" + FieldOrganizationGroupMemberEmail = "email" + + GroupMemberKindUser = "user" + GroupMemberKindServiceAccount = "service_account" +) + +var ( + supportedMemberKinds = []string{GroupMemberKindUser, GroupMemberKindServiceAccount} +) + +func resourceOrganizationGroup() *schema.Resource { + return &schema.Resource{ + ReadContext: resourceOrganizationGroupRead, + CreateContext: resourceOrganizationGroupCreate, + UpdateContext: resourceOrganizationGroupUpdate, + DeleteContext: resourceOrganizationGroupDelete, + Description: "CAST AI organization group resource to manage organization groups", + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + Timeouts: &schema.ResourceTimeout{ + Create: schema.DefaultTimeout(2 * time.Minute), + Update: schema.DefaultTimeout(2 * time.Minute), + Delete: schema.DefaultTimeout(2 * time.Minute), + }, + Schema: map[string]*schema.Schema{ + FieldOrganizationGroupOrganizationID: { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "CAST AI organization ID.", + }, + FieldOrganizationGroupName: { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "Name of the group.", + }, + FieldOrganizationGroupDescription: { + Type: schema.TypeString, + Optional: true, + Description: "Description of the group.", + }, + FieldOrganizationGroupMembers: { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + FieldOrganizationGroupMember: { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + FieldOrganizationGroupMemberKind: { + Type: schema.TypeString, + Required: true, + Description: fmt.Sprintf("Kind of the member. Supported values include: %s.", strings.Join(supportedMemberKinds, ", ")), + ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice(supportedMemberKinds, true)), + DiffSuppressFunc: func(k, oldValue, newValue string, d *schema.ResourceData) bool { + return strings.EqualFold(oldValue, newValue) + }, + }, + FieldOrganizationGroupMemberID: { + Type: schema.TypeString, + Required: true, + }, + FieldOrganizationGroupMemberEmail: { + Type: schema.TypeString, + Required: true, + }, + }, + }, + }, + }, + }, + }, + }, + } +} + +func resourceOrganizationGroupCreate(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics { + organizationID := data.Get(FieldOrganizationGroupOrganizationID).(string) + if organizationID == "" { + var err error + organizationID, err = getDefaultOrganizationId(ctx, meta) + if err != nil { + return diag.Errorf("getting default organization: %v", err) + } + } + + client := meta.(*ProviderConfig).api + + members := convertMembersToSDK(data) + + resp, err := client.RbacServiceAPICreateGroupWithResponse(ctx, organizationID, sdk.RbacServiceAPICreateGroupJSONRequestBody{ + Definition: sdk.CastaiRbacV1beta1CreateGroupRequestGroupDefinition{ + Members: &members, + }, + Description: lo.ToPtr(data.Get(FieldOrganizationGroupDescription).(string)), + Name: data.Get(FieldOrganizationName).(string), + }) + + if err := sdk.CheckOKResponse(resp, err); err != nil { + return diag.FromErr(fmt.Errorf("create group: %w", err)) + } + + data.SetId(*resp.JSON200.Id) + + return resourceOrganizationGroupRead(ctx, data, meta) +} + +func resourceOrganizationGroupRead(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics { + groupID := data.Id() + if groupID == "" { + return diag.Errorf("group ID is not set") + } + + organizationID := data.Get(FieldOrganizationGroupOrganizationID).(string) + if organizationID == "" { + var err error + organizationID, err = getDefaultOrganizationId(ctx, meta) + if err != nil { + return diag.FromErr(fmt.Errorf("getting default organization: %w", err)) + } + } + + client := meta.(*ProviderConfig).api + + group, err := getGroup(client, ctx, organizationID, groupID) + if err != nil { + return diag.FromErr(fmt.Errorf("getting group for read: %w", err)) + } + + if err := assignGroupData(group, data); err != nil { + return diag.FromErr(fmt.Errorf("assigning group data for read: %w", err)) + } + + return nil +} + +func resourceOrganizationGroupUpdate(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics { + groupID := data.Id() + if groupID == "" { + return diag.Errorf("group ID is not set") + } + + organizationID := data.Get(FieldOrganizationGroupOrganizationID).(string) + if organizationID == "" { + var err error + organizationID, err = getDefaultOrganizationId(ctx, meta) + if err != nil { + return diag.FromErr(fmt.Errorf("getting default organization: %w", err)) + } + } + + client := meta.(*ProviderConfig).api + + members := convertMembersToSDK(data) + + resp, err := client.RbacServiceAPIUpdateGroupWithResponse(ctx, organizationID, groupID, sdk.RbacServiceAPIUpdateGroupJSONRequestBody{ + Definition: sdk.CastaiRbacV1beta1UpdateGroupRequestGroupDefinition{ + Members: members, + }, + Description: lo.ToPtr(data.Get(FieldOrganizationGroupDescription).(string)), + Name: data.Get(FieldOrganizationName).(string), + }) + if err := sdk.CheckOKResponse(resp, err); err != nil { + return diag.FromErr(fmt.Errorf("update group: %w", err)) + } + + return resourceOrganizationGroupRead(ctx, data, meta) +} + +func resourceOrganizationGroupDelete(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics { + groupID := data.Id() + if groupID == "" { + return diag.Errorf("group ID is not set") + } + + organizationID := data.Get(FieldOrganizationGroupOrganizationID).(string) + if organizationID == "" { + var err error + organizationID, err = getDefaultOrganizationId(ctx, meta) + if err != nil { + return diag.FromErr(fmt.Errorf("getting default organization: %w", err)) + } + } + + client := meta.(*ProviderConfig).api + + resp, err := client.RbacServiceAPIDeleteGroupWithResponse(ctx, organizationID, groupID) + if err := sdk.CheckOKResponse(resp, err); err != nil { + return diag.FromErr(fmt.Errorf("destroy group: %w", err)) + } + + return nil +} + +func getGroup(client *sdk.ClientWithResponses, ctx context.Context, organizationID, groupID string) (*sdk.CastaiRbacV1beta1Group, error) { + groupsResp, err := client.RbacServiceAPIGetGroupWithResponse(ctx, organizationID, groupID) + if err != nil { + return nil, fmt.Errorf("fetching group: %w", err) + } + + if groupsResp.StatusCode() == http.StatusNotFound { + return nil, fmt.Errorf("group %s not found", groupID) + } + if err := sdk.CheckOKResponse(groupsResp, err); err != nil { + return nil, fmt.Errorf("retrieving group: %w", err) + } + if groupsResp.JSON200 == nil { + return nil, errors.New("group not found") + } + return groupsResp.JSON200, nil +} + +func assignGroupData(group *sdk.CastaiRbacV1beta1Group, data *schema.ResourceData) error { + if err := data.Set(FieldOrganizationGroupOrganizationID, group.OrganizationId); err != nil { + return fmt.Errorf("setting organization_id: %w", err) + } + if err := data.Set(FieldOrganizationGroupDescription, group.Description); err != nil { + return fmt.Errorf("setting description: %w", err) + } + if err := data.Set(FieldOrganizationGroupName, group.Name); err != nil { + return fmt.Errorf("setting group name: %w", err) + } + + if group.Definition.Members != nil { + var members []map[string]string + for _, member := range *group.Definition.Members { + var kind string + switch member.Kind { + case sdk.USER: + kind = GroupMemberKindUser + case sdk.SERVICEACCOUNT: + kind = GroupMemberKindServiceAccount + } + members = append(members, map[string]string{ + FieldOrganizationGroupMemberKind: kind, + FieldOrganizationGroupMemberID: member.Id, + FieldOrganizationGroupMemberEmail: member.Email, + }) + } + err := data.Set(FieldOrganizationGroupMembers, []any{ + map[string]any{ + FieldOrganizationGroupMember: members, + }, + }) + if err != nil { + return fmt.Errorf("parsing group members: %w", err) + } + } + + return nil +} + +func convertMembersToSDK(data *schema.ResourceData) []sdk.CastaiRbacV1beta1Member { + var members []sdk.CastaiRbacV1beta1Member + + for _, dataMembersDef := range data.Get(FieldOrganizationGroupMembers).([]any) { + for _, dataMember := range dataMembersDef.(map[string]any)[FieldOrganizationGroupMember].([]any) { + var kind sdk.CastaiRbacV1beta1MemberKind + switch dataMember.(map[string]any)[FieldOrganizationGroupMemberKind].(string) { + case GroupMemberKindUser: + kind = sdk.USER + case GroupMemberKindServiceAccount: + kind = sdk.SERVICEACCOUNT + } + members = append(members, sdk.CastaiRbacV1beta1Member{ + Kind: kind, + Email: dataMember.(map[string]any)[FieldOrganizationGroupMemberEmail].(string), + Id: dataMember.(map[string]any)[FieldOrganizationGroupMemberID].(string), + }) + } + } + + return members +} diff --git a/castai/resource_organization_group_test.go b/castai/resource_organization_group_test.go new file mode 100644 index 00000000..8af5967c --- /dev/null +++ b/castai/resource_organization_group_test.go @@ -0,0 +1,667 @@ +package castai + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "testing" + + "github.com/golang/mock/gomock" + "github.com/google/uuid" + "github.com/hashicorp/go-cty/cty" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "github.com/samber/lo" + "github.com/stretchr/testify/require" + + "github.com/castai/terraform-provider-castai/castai/sdk" + mock_sdk "github.com/castai/terraform-provider-castai/castai/sdk/mock" +) + +func TestOrganizationGroupReadContext(t *testing.T) { + t.Parallel() + + t.Run("when state is missing group ID then return error", func(t *testing.T) { + t.Parallel() + r := require.New(t) + + ctx := context.Background() + provider := &ProviderConfig{} + + stateValue := cty.ObjectVal(map[string]cty.Value{}) + state := terraform.NewInstanceStateShimmedFromValue(stateValue, 0) + + resource := resourceOrganizationGroup() + data := resource.Data(state) + + result := resource.ReadContext(ctx, data, provider) + + r.NotNil(result) + r.True(result.HasError()) + r.Len(result, 1) + r.Equal("group ID is not set", result[0].Summary) + }) + + t.Run("when RbacServiceAPI respond with 404 then return error", func(t *testing.T) { + t.Parallel() + r := require.New(t) + mockClient := mock_sdk.NewMockClientInterface(gomock.NewController(t)) + + ctx := context.Background() + provider := &ProviderConfig{ + api: &sdk.ClientWithResponses{ + ClientInterface: mockClient, + }, + } + + organizationID := uuid.NewString() + groupID := uuid.NewString() + + body := io.NopCloser(bytes.NewReader([]byte(""))) + + mockClient.EXPECT(). + RbacServiceAPIGetGroup(gomock.Any(), organizationID, groupID). + Return(&http.Response{StatusCode: http.StatusNotFound, Body: body, Header: map[string][]string{"Content-Type": {"json"}}}, nil) + + stateValue := cty.ObjectVal(map[string]cty.Value{ + "organization_id": cty.StringVal(organizationID), + }) + state := terraform.NewInstanceStateShimmedFromValue(stateValue, 0) + state.ID = groupID + + resource := resourceOrganizationGroup() + data := resource.Data(state) + + result := resource.ReadContext(ctx, data, provider) + r.NotNil(result) + r.True(result.HasError()) + r.Len(result, 1) + r.Equal("getting group for read: group "+groupID+" not found", result[0].Summary) + }) + + t.Run("when RbacServiceAPI respond with 500 then return error", func(t *testing.T) { + t.Parallel() + r := require.New(t) + mockClient := mock_sdk.NewMockClientInterface(gomock.NewController(t)) + + ctx := context.Background() + provider := &ProviderConfig{ + api: &sdk.ClientWithResponses{ + ClientInterface: mockClient, + }, + } + + organizationID := uuid.NewString() + groupID := uuid.NewString() + + body := io.NopCloser(bytes.NewReader([]byte("internal error"))) + + mockClient.EXPECT(). + RbacServiceAPIGetGroup(gomock.Any(), organizationID, groupID). + Return(&http.Response{StatusCode: http.StatusInternalServerError, Body: body, Header: map[string][]string{"Content-Type": {"json"}}}, nil) + + stateValue := cty.ObjectVal(map[string]cty.Value{ + "organization_id": cty.StringVal(organizationID), + }) + state := terraform.NewInstanceStateShimmedFromValue(stateValue, 0) + state.ID = groupID + + resource := resourceOrganizationGroup() + data := resource.Data(state) + + result := resource.ReadContext(ctx, data, provider) + r.NotNil(result) + r.True(result.HasError()) + r.Len(result, 1) + r.Equal("getting group for read: retrieving group: expected status code 200, received: status=500 body=internal error", result[0].Summary) + }) + + t.Run("when calling RbacServiceAPI throws error then return error", func(t *testing.T) { + t.Parallel() + r := require.New(t) + mockClient := mock_sdk.NewMockClientInterface(gomock.NewController(t)) + + ctx := context.Background() + provider := &ProviderConfig{ + api: &sdk.ClientWithResponses{ + ClientInterface: mockClient, + }, + } + + organizationID := uuid.NewString() + groupID := uuid.NewString() + + mockClient.EXPECT(). + RbacServiceAPIGetGroup(gomock.Any(), organizationID, groupID). + Return(nil, errors.New("unexpected error")) + + stateValue := cty.ObjectVal(map[string]cty.Value{ + "organization_id": cty.StringVal(organizationID), + }) + state := terraform.NewInstanceStateShimmedFromValue(stateValue, 0) + state.ID = groupID + + resource := resourceOrganizationGroup() + data := resource.Data(state) + + result := resource.ReadContext(ctx, data, provider) + r.NotNil(result) + r.True(result.HasError()) + r.Len(result, 1) + r.Equal("getting group for read: fetching group: unexpected error", result[0].Summary) + }) + + t.Run("when RbacServiceAPI respond with 200 then populate the state", func(t *testing.T) { + t.Parallel() + r := require.New(t) + mockClient := mock_sdk.NewMockClientInterface(gomock.NewController(t)) + + ctx := context.Background() + provider := &ProviderConfig{ + api: &sdk.ClientWithResponses{ + ClientInterface: mockClient, + }, + } + + organizationID := uuid.NewString() + groupID := uuid.NewString() + + firstUserID := uuid.NewString() + secondUserID := uuid.NewString() + + body := io.NopCloser(bytes.NewReader([]byte(`{ + "id": "` + groupID + `", + "organizationId": "` + organizationID + `", + "name": "test group", + "description": "test group description", + "definition": { + "members": [ + { + "id": "` + firstUserID + `", + "email": "test-user-1@test.com" + }, + { + "id": "` + secondUserID + `", + "email": "test-user-2@test.com" + } + ] + } + }`))) + + mockClient.EXPECT(). + RbacServiceAPIGetGroup(gomock.Any(), organizationID, groupID). + Return(&http.Response{StatusCode: http.StatusOK, Body: body, Header: map[string][]string{"Content-Type": {"json"}}}, nil) + + stateValue := cty.ObjectVal(map[string]cty.Value{ + "organization_id": cty.StringVal(organizationID), + }) + state := terraform.NewInstanceStateShimmedFromValue(stateValue, 0) + state.ID = groupID + + resource := resourceOrganizationGroup() + data := resource.Data(state) + + result := resource.ReadContext(ctx, data, provider) + r.Nil(result) + r.False(result.HasError()) + r.Equal(`ID = `+groupID+` +description = test group description +members.# = 1 +members.0.member.# = 2 +members.0.member.0.email = test-user-1@test.com +members.0.member.0.id = `+firstUserID+` +members.0.member.0.kind = +members.0.member.1.email = test-user-2@test.com +members.0.member.1.id = `+secondUserID+` +members.0.member.1.kind = +name = test group +organization_id = `+organizationID+` +Tainted = false +`, data.State().String()) + }) + + t.Run("when organization is not defined, use default one for the token", func(t *testing.T) { + t.Parallel() + r := require.New(t) + + mockClient := mock_sdk.NewMockClientInterface(gomock.NewController(t)) + + ctx := context.Background() + provider := &ProviderConfig{ + api: &sdk.ClientWithResponses{ + ClientInterface: mockClient, + }, + } + + organizationID := "b6bfc024-a267-400f-b8f1-db0850c369b4" + groupID := "e9a3f787-15d4-4850-ae7c-b4864809aa55" + + organizationsBody := io.NopCloser(bytes.NewReader([]byte(`{ + "organizations": [ + { + "id": "b6bfc024-a267-400f-b8f1-db0850c369b4", + "name": "Test 1", + "createdAt": "2023-04-18T16:03:18.800099Z", + "role": "owner" + } + ] +}`))) + + mockClient.EXPECT(). + UsersAPIListOrganizations(gomock.Any(), gomock.Any()). + Return(&http.Response{StatusCode: http.StatusOK, Body: organizationsBody, Header: map[string][]string{"Content-Type": {"json"}}}, nil) + + body := io.NopCloser(bytes.NewReader([]byte(`{ + "id": "e9a3f787-15d4-4850-ae7c-b4864809aa55", + "organizationId": "b6bfc024-a267-400f-b8f1-db0850c369b4", + "name": "test group", + "description": "test group description", + "definition": { + "members": [ + { + "id": "5d832285-c263-4d27-9ba5-7d8cf5759782", + "email": "test-user-1@test.com" + }, + { + "id": "5d832285-c263-4d27-9ba5-7d8cf5759783", + "email": "test-user-2@test.com" + } + ] + } + }`))) + + mockClient.EXPECT(). + RbacServiceAPIGetGroup(gomock.Any(), organizationID, groupID). + Return(&http.Response{StatusCode: http.StatusOK, Body: body, Header: map[string][]string{"Content-Type": {"json"}}}, nil) + + stateValue := cty.ObjectVal(map[string]cty.Value{}) + state := terraform.NewInstanceStateShimmedFromValue(stateValue, 0) + state.ID = groupID + + resource := resourceOrganizationGroup() + data := resource.Data(state) + + result := resource.ReadContext(ctx, data, provider) + r.Nil(result) + r.False(result.HasError()) + r.Equal(`ID = e9a3f787-15d4-4850-ae7c-b4864809aa55 +description = test group description +members.# = 1 +members.0.member.# = 2 +members.0.member.0.email = test-user-1@test.com +members.0.member.0.id = 5d832285-c263-4d27-9ba5-7d8cf5759782 +members.0.member.0.kind = +members.0.member.1.email = test-user-2@test.com +members.0.member.1.id = 5d832285-c263-4d27-9ba5-7d8cf5759783 +members.0.member.1.kind = +name = test group +organization_id = b6bfc024-a267-400f-b8f1-db0850c369b4 +Tainted = false +`, data.State().String()) + }) + +} + +func TestOrganizationGroupUpdateContext(t *testing.T) { + t.Parallel() + + t.Run("when state is missing group ID then return error", func(t *testing.T) { + t.Parallel() + r := require.New(t) + + ctx := context.Background() + provider := &ProviderConfig{} + + stateValue := cty.ObjectVal(map[string]cty.Value{}) + state := terraform.NewInstanceStateShimmedFromValue(stateValue, 0) + + resource := resourceOrganizationGroup() + data := resource.Data(state) + + result := resource.UpdateContext(ctx, data, provider) + + r.NotNil(result) + r.True(result.HasError()) + r.Len(result, 1) + r.Equal("group ID is not set", result[0].Summary) + }) + + t.Run("when RbacServiceAPI UpdateGroup respond with 500 then throw error", func(t *testing.T) { + t.Parallel() + r := require.New(t) + mockClient := mock_sdk.NewMockClientInterface(gomock.NewController(t)) + + ctx := context.Background() + provider := &ProviderConfig{ + api: &sdk.ClientWithResponses{ + ClientInterface: mockClient, + }, + } + + organizationID := uuid.NewString() + groupID := uuid.NewString() + + mockClient.EXPECT(). + RbacServiceAPIUpdateGroup(gomock.Any(), organizationID, groupID, gomock.Any()). + DoAndReturn(func(ctx context.Context, reqOrgID string, reqGroupID string, req sdk.RbacServiceAPIUpdateGroupJSONRequestBody) (*http.Response, error) { + r.Equal(organizationID, reqOrgID) + r.Equal(groupID, reqGroupID) + + body := &bytes.Buffer{} + err := json.NewEncoder(body).Encode(&sdk.CastaiRbacV1beta1Group{}) + r.NoError(err) + return &http.Response{StatusCode: http.StatusInternalServerError, Body: io.NopCloser(body), Header: map[string][]string{"Content-Type": {"json"}}}, nil + }) + + stateValue := cty.ObjectVal(map[string]cty.Value{ + "organization_id": cty.StringVal(organizationID), + }) + state := terraform.NewInstanceStateShimmedFromValue(stateValue, 0) + state.ID = groupID + + resource := resourceOrganizationGroup() + data := resource.Data(state) + + result := resource.UpdateContext(ctx, data, provider) + + r.NotNil(result) + r.True(result.HasError()) + r.Len(result, 1) + r.Contains(result[0].Summary, "update group: expected status code 200, received: status=500") + }) + + t.Run("when RbacServiceAPI UpdateGroup respond with 200 then no errors", func(t *testing.T) { + t.Parallel() + r := require.New(t) + mockClient := mock_sdk.NewMockClientInterface(gomock.NewController(t)) + + ctx := context.Background() + provider := &ProviderConfig{ + api: &sdk.ClientWithResponses{ + ClientInterface: mockClient, + }, + } + + organizationID := uuid.NewString() + groupID := uuid.NewString() + + firstUserID := uuid.NewString() + secondUserID := uuid.NewString() + + body := io.NopCloser(bytes.NewReader([]byte(`{ + "id": "` + groupID + `", + "organizationId": "` + organizationID + `", + "name": "test group", + "description": "test group description changed", + "definition": { + "members": [ + { + "id": "` + firstUserID + `", + "email": "test-user-1@test.com" + }, + { + "id": "` + secondUserID + `", + "email": "test-user-2@test.com" + } + ] + } + }`))) + + mockClient.EXPECT(). + RbacServiceAPIGetGroup(gomock.Any(), organizationID, groupID). + Return(&http.Response{StatusCode: http.StatusOK, Body: body, Header: map[string][]string{"Content-Type": {"json"}}}, nil) + + mockClient.EXPECT(). + RbacServiceAPIUpdateGroup(gomock.Any(), organizationID, groupID, gomock.Any()). + DoAndReturn(func(ctx context.Context, reqOrgID string, reqGroupID string, req sdk.RbacServiceAPIUpdateGroupJSONRequestBody) (*http.Response, error) { + r.Equal(organizationID, reqOrgID) + r.Equal(groupID, reqGroupID) + + body := &bytes.Buffer{} + err := json.NewEncoder(body).Encode(&sdk.CastaiRbacV1beta1Group{}) + r.NoError(err) + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(body), Header: map[string][]string{"Content-Type": {"json"}}}, nil + }) + + stateValue := cty.ObjectVal(map[string]cty.Value{ + "organization_id": cty.StringVal(organizationID), + }) + state := terraform.NewInstanceStateShimmedFromValue(stateValue, 0) + state.ID = groupID + + resource := resourceOrganizationGroup() + data := resource.Data(state) + + result := resource.UpdateContext(ctx, data, provider) + + r.Nil(result) + r.False(result.HasError()) + }) +} + +func TestOrganizationGroupCreateContext(t *testing.T) { + t.Parallel() + + t.Run("when RbacServiceAPI CreateGroup respond with 500 then throw error", func(t *testing.T) { + t.Parallel() + r := require.New(t) + mockClient := mock_sdk.NewMockClientInterface(gomock.NewController(t)) + + ctx := context.Background() + provider := &ProviderConfig{ + api: &sdk.ClientWithResponses{ + ClientInterface: mockClient, + }, + } + + organizationID := uuid.NewString() + + mockClient.EXPECT(). + RbacServiceAPICreateGroup(gomock.Any(), organizationID, gomock.Any()). + DoAndReturn(func(ctx context.Context, reqOrgID string, req sdk.RbacServiceAPICreateGroupJSONRequestBody) (*http.Response, error) { + r.Equal(organizationID, reqOrgID) + + body := &bytes.Buffer{} + err := json.NewEncoder(body).Encode(&sdk.CastaiRbacV1beta1Group{}) + r.NoError(err) + return &http.Response{StatusCode: http.StatusInternalServerError, Body: io.NopCloser(body), Header: map[string][]string{"Content-Type": {"json"}}}, nil + }) + + stateValue := cty.ObjectVal(map[string]cty.Value{ + "organization_id": cty.StringVal(organizationID), + }) + state := terraform.NewInstanceStateShimmedFromValue(stateValue, 0) + + resource := resourceOrganizationGroup() + data := resource.Data(state) + + result := resource.CreateContext(ctx, data, provider) + + r.NotNil(result) + r.True(result.HasError()) + r.Len(result, 1) + r.Contains(result[0].Summary, "create group: expected status code 200, received: status=500") + }) + + t.Run("when RbacServiceAPI CreateGroup respond with 200 then assume group was created", func(t *testing.T) { + t.Parallel() + r := require.New(t) + mockClient := mock_sdk.NewMockClientInterface(gomock.NewController(t)) + + ctx := context.Background() + provider := &ProviderConfig{ + api: &sdk.ClientWithResponses{ + ClientInterface: mockClient, + }, + } + + organizationID := uuid.NewString() + groupID := uuid.NewString() + + firstUserID := uuid.NewString() + secondUserID := uuid.NewString() + + body := io.NopCloser(bytes.NewReader([]byte(`{ + "id": "` + groupID + `", + "organizationId": "` + organizationID + `", + "name": "test group", + "description": "test group description changed", + "definition": { + "members": [ + { + "id": "` + firstUserID + `", + "email": "test-user-1@test.com" + }, + { + "id": "` + secondUserID + `", + "email": "test-user-2@test.com" + } + ] + } + }`))) + + mockClient.EXPECT(). + RbacServiceAPIGetGroup(gomock.Any(), organizationID, groupID). + Return(&http.Response{StatusCode: http.StatusOK, Body: body, Header: map[string][]string{"Content-Type": {"json"}}}, nil) + + mockClient.EXPECT(). + RbacServiceAPICreateGroup(gomock.Any(), organizationID, gomock.Any()). + DoAndReturn(func(ctx context.Context, reqOrgID string, req sdk.RbacServiceAPICreateGroupJSONRequestBody) (*http.Response, error) { + r.Equal(organizationID, reqOrgID) + + body := bytes.NewBuffer([]byte("")) + err := json.NewEncoder(body).Encode(&sdk.CastaiRbacV1beta1Group{ + Id: lo.ToPtr(groupID), + }) + r.NoError(err) + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(body), Header: map[string][]string{"Content-Type": {"json"}}}, nil + }) + + stateValue := cty.ObjectVal(map[string]cty.Value{ + "organization_id": cty.StringVal(organizationID), + }) + state := terraform.NewInstanceStateShimmedFromValue(stateValue, 0) + state.ID = groupID + + resource := resourceOrganizationGroup() + data := resource.Data(state) + + result := resource.CreateContext(ctx, data, provider) + + r.Nil(result) + r.False(result.HasError()) + }) +} + +func TestOrganizationGroupDeleteContext(t *testing.T) { + t.Parallel() + + t.Run("when state is missing group ID then return error", func(t *testing.T) { + t.Parallel() + r := require.New(t) + + ctx := context.Background() + provider := &ProviderConfig{} + + stateValue := cty.ObjectVal(map[string]cty.Value{}) + state := terraform.NewInstanceStateShimmedFromValue(stateValue, 0) + + resource := resourceOrganizationGroup() + data := resource.Data(state) + + result := resource.DeleteContext(ctx, data, provider) + + r.NotNil(result) + r.True(result.HasError()) + r.Len(result, 1) + r.Equal("group ID is not set", result[0].Summary) + }) + + t.Run("when RbacServiceAPI DeleteGroup respond with 500 then throw error", func(t *testing.T) { + t.Parallel() + r := require.New(t) + mockClient := mock_sdk.NewMockClientInterface(gomock.NewController(t)) + + ctx := context.Background() + provider := &ProviderConfig{ + api: &sdk.ClientWithResponses{ + ClientInterface: mockClient, + }, + } + + organizationID := uuid.NewString() + groupID := uuid.NewString() + + mockClient.EXPECT(). + RbacServiceAPIDeleteGroup(gomock.Any(), organizationID, groupID, gomock.Any()). + DoAndReturn(func(ctx context.Context, reqOrgID string, reqGroupID string) (*http.Response, error) { + r.Equal(organizationID, reqOrgID) + r.Equal(groupID, reqGroupID) + + body := &bytes.Buffer{} + err := json.NewEncoder(body).Encode(&sdk.CastaiRbacV1beta1Group{}) + r.NoError(err) + return &http.Response{StatusCode: http.StatusInternalServerError, Body: io.NopCloser(body), Header: map[string][]string{"Content-Type": {"json"}}}, nil + }) + + stateValue := cty.ObjectVal(map[string]cty.Value{ + "organization_id": cty.StringVal(organizationID), + }) + state := terraform.NewInstanceStateShimmedFromValue(stateValue, 0) + state.ID = groupID + + resource := resourceOrganizationGroup() + data := resource.Data(state) + + result := resource.DeleteContext(ctx, data, provider) + + r.NotNil(result) + r.True(result.HasError()) + r.Len(result, 1) + r.Contains(result[0].Summary, "destroy group: expected status code 200, received: status=500") + }) + + t.Run("when RbacServiceAPI DeleteGroup respond with 200 then no errors", func(t *testing.T) { + t.Parallel() + r := require.New(t) + mockClient := mock_sdk.NewMockClientInterface(gomock.NewController(t)) + + ctx := context.Background() + provider := &ProviderConfig{ + api: &sdk.ClientWithResponses{ + ClientInterface: mockClient, + }, + } + + organizationID := uuid.NewString() + groupID := uuid.NewString() + + mockClient.EXPECT(). + RbacServiceAPIDeleteGroup(gomock.Any(), organizationID, groupID, gomock.Any()). + DoAndReturn(func(ctx context.Context, reqOrgID string, reqGroupID string) (*http.Response, error) { + r.Equal(organizationID, reqOrgID) + r.Equal(groupID, reqGroupID) + + body := &bytes.Buffer{} + err := json.NewEncoder(body).Encode(&sdk.CastaiRbacV1beta1Group{}) + r.NoError(err) + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(body), Header: map[string][]string{"Content-Type": {"json"}}}, nil + }) + + stateValue := cty.ObjectVal(map[string]cty.Value{ + "organization_id": cty.StringVal(organizationID), + }) + state := terraform.NewInstanceStateShimmedFromValue(stateValue, 0) + state.ID = groupID + + resource := resourceOrganizationGroup() + data := resource.Data(state) + + result := resource.DeleteContext(ctx, data, provider) + + r.Nil(result) + r.False(result.HasError()) + }) +} diff --git a/docs/resources/node_configuration.md b/docs/resources/node_configuration.md index 08f35f4a..669a4edb 100644 --- a/docs/resources/node_configuration.md +++ b/docs/resources/node_configuration.md @@ -88,20 +88,19 @@ resource "castai_node_configuration" "default" { Optional: - `aks_image_family` (String) Image OS Family to use when provisioning node in AKS. If both image and family are provided, the system will use provided image and provisioning logic for given family. If only image family is provided, the system will attempt to resolve the latest image from that family based on kubernetes version and node architecture. If image family is omitted, a default family (based on cloud provider) will be used. See Cast.ai documentation for details. Possible values: (ubuntu,azure-linux) -- `loadbalancers` (Block List) Loadboalancer configuration for CAST provisioned nodes (see [below for nested schema](#nestedblock--aks--loadbalancers)) +- `loadbalancers` (Block List) Load balancer configuration for CAST provisioned nodes (see [below for nested schema](#nestedblock--aks--loadbalancers)) - `max_pods_per_node` (Number) Maximum number of pods that can be run on a node, which affects how many IP addresses you will need for each node. Defaults to 30 - `os_disk_type` (String) Type of managed os disk attached to the node. (See [disk types](https://learn.microsoft.com/en-us/azure/virtual-machines/disks-types)). One of: standard, standard-ssd, premium-ssd (ultra and premium-ssd-v2 are not supported for os disk) ### Nested Schema for `aks.loadbalancers` -Required: - -- `name` (String) Name of loadbalancer - Optional: +- `id` (String) The full ID of the load balancer in azure. - `ip_based_backend_pools` (Block List) IP based backend pools configuration for CAST provisioned nodes (see [below for nested schema](#nestedblock--aks--loadbalancers--ip_based_backend_pools)) +- `name` (String, Deprecated) Name of load balancer +- `nic_based_backend_pools` (Block List) NIC based backend pools configuration for CAST provisioned nodes. (see [below for nested schema](#nestedblock--aks--loadbalancers--nic_based_backend_pools)) ### Nested Schema for `aks.loadbalancers.ip_based_backend_pools` @@ -111,6 +110,14 @@ Required: - `name` (String) Name of the ip based backend pool + +### Nested Schema for `aks.loadbalancers.nic_based_backend_pools` + +Required: + +- `name` (String) Name of the NIC based backend pool + + diff --git a/docs/resources/organization_group.md b/docs/resources/organization_group.md new file mode 100644 index 00000000..68300dc9 --- /dev/null +++ b/docs/resources/organization_group.md @@ -0,0 +1,63 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "castai_organization_group Resource - terraform-provider-castai" +subcategory: "" +description: |- + CAST AI organization group resource to manage organization groups +--- + +# castai_organization_group (Resource) + +CAST AI organization group resource to manage organization groups + + + + +## Schema + +### Required + +- `name` (String) Name of the group. +- `organization_id` (String) CAST AI organization ID. + +### Optional + +- `description` (String) Description of the group. +- `members` (Block List) (see [below for nested schema](#nestedblock--members)) +- `timeouts` (Block, Optional) (see [below for nested schema](#nestedblock--timeouts)) + +### Read-Only + +- `id` (String) The ID of this resource. + + +### Nested Schema for `members` + +Optional: + +- `member` (Block List) (see [below for nested schema](#nestedblock--members--member)) + + +### Nested Schema for `members.member` + +Required: + +- `email` (String) +- `kind` (String) Kind of the member. Supported values include: user, service_account. + +Read-Only: + +- `id` (String) The ID of this resource. + + + + +### Nested Schema for `timeouts` + +Optional: + +- `create` (String) +- `delete` (String) +- `update` (String) + + diff --git a/examples/organization_groups/main.tf b/examples/organization_groups/main.tf new file mode 100644 index 00000000..4093c43e --- /dev/null +++ b/examples/organization_groups/main.tf @@ -0,0 +1,50 @@ +terraform { + required_providers { + castai = { + source = "castai/castai" + } + } + required_version = ">= 0.13" +} + +data "castai_organization" "test" { + name = "My test organization name" +} + +resource "castai_organization_group" "first_group" { + organization_id = data.castai_organization.test.id + name = "first-group" + description = "A description of the first group." + + members { + member { + kind = "user" + id = "21c133e2-a899-4f51-b297-830bc62e51d6" + email = "first-user@cast.ai" + } + member { + kind = "user" + id = "21c133e2-a899-4f51-b297-830bc62e51d7" + email = "second-user@cast.ai" + } + member { + kind = "service_account" + id = "21c133e2-a899-4f51-b297-830bc62e51d9" + email = "service_account-2@cast.ai" + } + } +} + +resource "castai_organization_group" "second_group" { + organization_id = data.castai_organization.test.id + name = "second-group" + description = "A description of the second group." + + members { + member { + kind = "user" + id = "21c133e2-a899-4f51-b297-830bc62e51d6" + email = "first-user@cast.ai" + } + } +} \ No newline at end of file