From 7e06fa4da716433ff7b0192bb0ac33a9c84fa072 Mon Sep 17 00:00:00 2001 From: ecmadao Date: Wed, 11 Dec 2024 18:50:19 +0800 Subject: [PATCH 1/9] chore: support set roles for user in workspace level --- api/client.go | 10 +- client/project.go | 6 +- client/vcs.go | 4 +- client/workspace.go | 52 +++++++ examples/setup/main.tf | 12 ++ provider/data_source_user.go | 34 ++++- provider/data_source_user_list.go | 14 ++ provider/internal/mock_client.go | 235 ++++++++++++++++++++++++------ provider/resource_project.go | 9 +- provider/resource_user.go | 98 +++++++++++-- 10 files changed, 405 insertions(+), 69 deletions(-) create mode 100644 client/workspace.go diff --git a/api/client.go b/api/client.go index b161627..22068e1 100644 --- a/api/client.go +++ b/api/client.go @@ -76,7 +76,7 @@ type Client interface { // GetProjectIAMPolicy gets the project IAM policy by project full name. GetProjectIAMPolicy(ctx context.Context, projectName string) (*v1pb.IamPolicy, error) // SetProjectIAMPolicy sets the project IAM policy. - SetProjectIAMPolicy(ctx context.Context, projectName string, iamPolicy *v1pb.IamPolicy) (*v1pb.IamPolicy, error) + SetProjectIAMPolicy(ctx context.Context, projectName string, update *v1pb.SetIamPolicyRequest) (*v1pb.IamPolicy, error) // Setting // ListSettings lists all settings. @@ -98,7 +98,7 @@ type Client interface { // CreateVCSProvider creates the vcs provider. CreateVCSProvider(ctx context.Context, vcsID string, vcs *v1pb.VCSProvider) (*v1pb.VCSProvider, error) // UpdateVCSProvider updates the vcs provider. - UpdateVCSProvider(ctx context.Context, patch *v1pb.VCSProvider, updateMasks []string) (*v1pb.VCSConnector, error) + UpdateVCSProvider(ctx context.Context, patch *v1pb.VCSProvider, updateMasks []string) (*v1pb.VCSProvider, error) // DeleteVCSProvider deletes the vcs provider. DeleteVCSProvider(ctx context.Context, name string) error @@ -127,4 +127,10 @@ type Client interface { DeleteUser(ctx context.Context, userName string) error // UndeleteUser undeletes the user by name. UndeleteUser(ctx context.Context, userName string) (*v1pb.User, error) + + // Workspace + // GetWorkspaceIAMPolicy gets the workspace IAM policy. + GetWorkspaceIAMPolicy(ctx context.Context) (*v1pb.IamPolicy, error) + // SetWorkspaceIAMPolicy sets the workspace IAM policy. + SetWorkspaceIAMPolicy(ctx context.Context, setIamPolicyRequest *v1pb.SetIamPolicyRequest) (*v1pb.IamPolicy, error) } diff --git a/client/project.go b/client/project.go index 60c1498..b7daa92 100644 --- a/client/project.go +++ b/client/project.go @@ -41,10 +41,8 @@ func (c *client) GetProjectIAMPolicy(ctx context.Context, projectName string) (* } // SetProjectIAMPolicy sets the project IAM policy. -func (c *client) SetProjectIAMPolicy(ctx context.Context, projectName string, iamPolicy *v1pb.IamPolicy) (*v1pb.IamPolicy, error) { - payload, err := protojson.Marshal(&v1pb.SetIamPolicyRequest{ - Policy: iamPolicy, - }) +func (c *client) SetProjectIAMPolicy(ctx context.Context, projectName string, update *v1pb.SetIamPolicyRequest) (*v1pb.IamPolicy, error) { + payload, err := protojson.Marshal(update) if err != nil { return nil, err } diff --git a/client/vcs.go b/client/vcs.go index 973641e..2edcfcd 100644 --- a/client/vcs.go +++ b/client/vcs.go @@ -72,13 +72,13 @@ func (c *client) CreateVCSProvider(ctx context.Context, vcsID string, vcs *v1pb. } // UpdateVCSProvider updates the vcs provider. -func (c *client) UpdateVCSProvider(ctx context.Context, patch *v1pb.VCSProvider, updateMasks []string) (*v1pb.VCSConnector, error) { +func (c *client) UpdateVCSProvider(ctx context.Context, patch *v1pb.VCSProvider, updateMasks []string) (*v1pb.VCSProvider, error) { body, err := c.updateResource(ctx, patch.Name, patch, updateMasks, false /* allow missing = false*/) if err != nil { return nil, err } - var res v1pb.VCSConnector + var res v1pb.VCSProvider if err := ProtojsonUnmarshaler.Unmarshal(body, &res); err != nil { return nil, err } diff --git a/client/workspace.go b/client/workspace.go new file mode 100644 index 0000000..989293e --- /dev/null +++ b/client/workspace.go @@ -0,0 +1,52 @@ +package client + +import ( + "context" + "fmt" + "net/http" + "strings" + + v1pb "github.com/bytebase/bytebase/proto/generated-go/v1" + "google.golang.org/protobuf/encoding/protojson" +) + +// GetWorkspaceIAMPolicy gets the workspace IAM policy. +func (c *client) GetWorkspaceIAMPolicy(ctx context.Context) (*v1pb.IamPolicy, error) { + body, err := c.getResource(ctx, "workspaces/-:getIamPolicy") + if err != nil { + return nil, err + } + + var res v1pb.IamPolicy + if err := ProtojsonUnmarshaler.Unmarshal(body, &res); err != nil { + return nil, err + } + + return &res, nil +} + +// SetWorkspaceIAMPolicy sets the workspace IAM policy. +func (c *client) SetWorkspaceIAMPolicy(ctx context.Context, setIamPolicyRequest *v1pb.SetIamPolicyRequest) (*v1pb.IamPolicy, error) { + payload, err := protojson.Marshal(setIamPolicyRequest) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("%s/%s/%s:setIamPolicy", c.url, c.version, "workspaces/-"), strings.NewReader(string(payload))) + + if err != nil { + return nil, err + } + + body, err := c.doRequest(req) + if err != nil { + return nil, err + } + + var res v1pb.IamPolicy + if err := ProtojsonUnmarshaler.Unmarshal(body, &res); err != nil { + return nil, err + } + + return &res, nil +} diff --git a/examples/setup/main.tf b/examples/setup/main.tf index d8697c7..3f58a6a 100644 --- a/examples/setup/main.tf +++ b/examples/setup/main.tf @@ -96,10 +96,22 @@ resource "bytebase_instance" "prod" { } } +# Create a new user. +resource "bytebase_user" "workspace_dba" { + title = "DBA" + email = "dba@bytebase.com" + + # Grant workspace level roles. + roles = ["roles/workspaceDBA"] +} + # Create a new user. resource "bytebase_user" "project_developer" { title = "Developer" email = "developer@bytebase.com" + + # Grant workspace level roles, will grant projectViewer for this user in all projects. + roles = ["roles/projectViewer"] } # Create a new project diff --git a/provider/data_source_user.go b/provider/data_source_user.go index 45b458b..6f6f380 100644 --- a/provider/data_source_user.go +++ b/provider/data_source_user.go @@ -43,6 +43,14 @@ func dataSourceUser() *schema.Resource { Computed: true, Description: "The user phone.", }, + "roles": { + Type: schema.TypeSet, + Computed: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + Description: "The user's roles in the workspace level", + }, "type": { Type: schema.TypeString, Computed: true, @@ -88,10 +96,29 @@ func dataSourceUserRead(ctx context.Context, d *schema.ResourceData, m interface d.SetId(user.Name) - return setUser(d, user) + return setUser(ctx, c, d, user) +} + +func getUserRoles(iamPolicy *v1pb.IamPolicy, email string) []string { + userBinding := fmt.Sprintf("user:%s", email) + roles := []string{} + + for _, binding := range iamPolicy.Bindings { + for _, member := range binding.Members { + if member == userBinding { + roles = append(roles, binding.Role) + } + } + } + return roles } -func setUser(d *schema.ResourceData, user *v1pb.User) diag.Diagnostics { +func setUser(ctx context.Context, client api.Client, d *schema.ResourceData, user *v1pb.User) diag.Diagnostics { + workspaceIAM, err := client.GetWorkspaceIAMPolicy(ctx) + if err != nil { + return diag.Errorf("cannot get workspace IAM with error: %s", err.Error()) + } + if err := d.Set("title", user.Title); err != nil { return diag.Errorf("cannot set title for user: %s", err.Error()) } @@ -121,6 +148,9 @@ func setUser(d *schema.ResourceData, user *v1pb.User) diag.Diagnostics { return diag.Errorf("cannot set source for user: %s", err.Error()) } } + if err := d.Set("roles", getUserRoles(workspaceIAM, user.Email)); err != nil { + return diag.Errorf("cannot set roles for user: %s", err.Error()) + } return nil } diff --git a/provider/data_source_user_list.go b/provider/data_source_user_list.go index f53bdbb..8acb045 100644 --- a/provider/data_source_user_list.go +++ b/provider/data_source_user_list.go @@ -52,6 +52,14 @@ func dataSourceUserList() *schema.Resource { Computed: true, Description: "The user type.", }, + "roles": { + Type: schema.TypeSet, + Computed: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + Description: "The user's roles in the workspace level", + }, "mfa_enabled": { Type: schema.TypeBool, Computed: true, @@ -92,6 +100,11 @@ func dataSourceUserListRead(ctx context.Context, d *schema.ResourceData, m inter return diag.FromErr(err) } + workspaceIAM, err := c.GetWorkspaceIAMPolicy(ctx) + if err != nil { + return diag.Errorf("cannot get workspace IAM with error: %s", err.Error()) + } + users := make([]map[string]interface{}, 0) for _, user := range response.Users { raw := make(map[string]interface{}) @@ -107,6 +120,7 @@ func dataSourceUserListRead(ctx context.Context, d *schema.ResourceData, m inter raw["last_login_time"] = p.LastLoginTime.AsTime().UTC().Format(time.RFC3339) raw["last_change_password_time"] = p.LastChangePasswordTime.AsTime().UTC().Format(time.RFC3339) } + raw["roles"] = getUserRoles(workspaceIAM, user.Email) users = append(users, raw) } if err := d.Set("users", users); err != nil { diff --git a/provider/internal/mock_client.go b/provider/internal/mock_client.go index ca7c81e..dd184da 100644 --- a/provider/internal/mock_client.go +++ b/provider/internal/mock_client.go @@ -18,36 +18,54 @@ var environmentMap map[string]*v1pb.Environment var instanceMap map[string]*v1pb.Instance var policyMap map[string]*v1pb.Policy var projectMap map[string]*v1pb.Project +var projectIAMMap map[string]*v1pb.IamPolicy var databaseMap map[string]*v1pb.Database var settingMap map[string]*v1pb.Setting +var vcsProviderMap map[string]*v1pb.VCSProvider +var vcsConnectorMap map[string]*v1pb.VCSConnector +var userMap map[string]*v1pb.User func init() { environmentMap = map[string]*v1pb.Environment{} instanceMap = map[string]*v1pb.Instance{} policyMap = map[string]*v1pb.Policy{} projectMap = map[string]*v1pb.Project{} + projectIAMMap = map[string]*v1pb.IamPolicy{} databaseMap = map[string]*v1pb.Database{} settingMap = map[string]*v1pb.Setting{} + vcsProviderMap = map[string]*v1pb.VCSProvider{} + vcsConnectorMap = map[string]*v1pb.VCSConnector{} + userMap = map[string]*v1pb.User{} } type mockClient struct { - environmentMap map[string]*v1pb.Environment - instanceMap map[string]*v1pb.Instance - policyMap map[string]*v1pb.Policy - projectMap map[string]*v1pb.Project - databaseMap map[string]*v1pb.Database - settingMap map[string]*v1pb.Setting + environmentMap map[string]*v1pb.Environment + instanceMap map[string]*v1pb.Instance + policyMap map[string]*v1pb.Policy + projectMap map[string]*v1pb.Project + projectIAMMap map[string]*v1pb.IamPolicy + databaseMap map[string]*v1pb.Database + settingMap map[string]*v1pb.Setting + vcsProviderMap map[string]*v1pb.VCSProvider + vcsConnectorMap map[string]*v1pb.VCSConnector + userMap map[string]*v1pb.User + workspaceIAMPolicy *v1pb.IamPolicy } // newMockClient returns the new Bytebase API mock client. func newMockClient(_, _, _ string) (api.Client, error) { return &mockClient{ - environmentMap: environmentMap, - instanceMap: instanceMap, - policyMap: policyMap, - projectMap: projectMap, - databaseMap: databaseMap, - settingMap: settingMap, + environmentMap: environmentMap, + instanceMap: instanceMap, + policyMap: policyMap, + projectMap: projectMap, + projectIAMMap: projectIAMMap, + databaseMap: databaseMap, + settingMap: settingMap, + vcsProviderMap: vcsProviderMap, + vcsConnectorMap: vcsConnectorMap, + userMap: userMap, + workspaceIAMPolicy: &v1pb.IamPolicy{}, }, nil } @@ -474,13 +492,18 @@ func (c *mockClient) UndeleteProject(ctx context.Context, projectName string) (* } // GetProjectIAMPolicy gets the project IAM policy by project full name. -func (*mockClient) GetProjectIAMPolicy(_ context.Context, _ string) (*v1pb.IamPolicy, error) { - return &v1pb.IamPolicy{}, nil +func (m *mockClient) GetProjectIAMPolicy(_ context.Context, projectName string) (*v1pb.IamPolicy, error) { + iamPolicy, ok := m.projectIAMMap[projectName] + if !ok { + return &v1pb.IamPolicy{}, nil + } + return iamPolicy, nil } // SetProjectIAMPolicy sets the project IAM policy. -func (*mockClient) SetProjectIAMPolicy(_ context.Context, _ string, _ *v1pb.IamPolicy) (*v1pb.IamPolicy, error) { - return &v1pb.IamPolicy{}, nil +func (m *mockClient) SetProjectIAMPolicy(_ context.Context, projectName string, update *v1pb.SetIamPolicyRequest) (*v1pb.IamPolicy, error) { + m.projectIAMMap[projectName] = update.Policy + return m.projectIAMMap[projectName], nil } // ListSettings lists all settings. @@ -519,85 +542,205 @@ func (c *mockClient) UpsertSetting(_ context.Context, upsert *v1pb.Setting, _ [] // ParseExpression parse the expression string. func (*mockClient) ParseExpression(_ context.Context, _ string) (*v1alpha1.Expr, error) { - return nil, nil + return &v1alpha1.Expr{}, nil } // ListVCSProvider will returns all vcs providers. -func (*mockClient) ListVCSProvider(_ context.Context) (*v1pb.ListVCSProvidersResponse, error) { - return nil, nil +func (c *mockClient) ListVCSProvider(_ context.Context) (*v1pb.ListVCSProvidersResponse, error) { + providers := make([]*v1pb.VCSProvider, 0) + for _, provider := range c.vcsProviderMap { + providers = append(providers, provider) + } + + return &v1pb.ListVCSProvidersResponse{ + VcsProviders: providers, + }, nil } // GetVCSProvider gets the vcs by id. -func (*mockClient) GetVCSProvider(_ context.Context, _ string) (*v1pb.VCSProvider, error) { - return nil, nil +func (c *mockClient) GetVCSProvider(_ context.Context, providerName string) (*v1pb.VCSProvider, error) { + provider, ok := c.vcsProviderMap[providerName] + if !ok { + return nil, errors.Errorf("Cannot found provider %s", providerName) + } + + return provider, nil } // CreateVCSProvider creates the vcs provider. -func (*mockClient) CreateVCSProvider(_ context.Context, _ string, _ *v1pb.VCSProvider) (*v1pb.VCSProvider, error) { - return nil, nil +func (c *mockClient) CreateVCSProvider(_ context.Context, providerID string, provider *v1pb.VCSProvider) (*v1pb.VCSProvider, error) { + providerName := fmt.Sprintf("%s%s", VCSProviderNamePrefix, providerID) + c.vcsProviderMap[providerName] = provider + return provider, nil } // UpdateVCSProvider updates the vcs provider. -func (*mockClient) UpdateVCSProvider(_ context.Context, _ *v1pb.VCSProvider, _ []string) (*v1pb.VCSConnector, error) { - return nil, nil +func (c *mockClient) UpdateVCSProvider(ctx context.Context, provider *v1pb.VCSProvider, updateMasks []string) (*v1pb.VCSProvider, error) { + existed, err := c.GetVCSProvider(ctx, provider.Name) + if err != nil { + return nil, err + } + if slices.Contains(updateMasks, "title") { + existed.Title = provider.Title + } + if slices.Contains(updateMasks, "access_token") { + existed.AccessToken = provider.AccessToken + } + c.vcsProviderMap[provider.Name] = existed + return c.vcsProviderMap[provider.Name], nil } // DeleteVCSProvider deletes the vcs provider. -func (*mockClient) DeleteVCSProvider(_ context.Context, _ string) error { +func (c *mockClient) DeleteVCSProvider(_ context.Context, provider string) error { + delete(c.vcsProviderMap, provider) return nil } // ListVCSConnector will returns all vcs connector in a project. -func (*mockClient) ListVCSConnector(_ context.Context, _ string) (*v1pb.ListVCSConnectorsResponse, error) { - return nil, nil +func (c *mockClient) ListVCSConnector(_ context.Context, projectName string) (*v1pb.ListVCSConnectorsResponse, error) { + connectors := make([]*v1pb.VCSConnector, 0) + for _, connector := range c.vcsConnectorMap { + if strings.HasPrefix(connector.Name, fmt.Sprintf("%s/%s", projectName, VCSConnectorNamePrefix)) { + connectors = append(connectors, connector) + } + } + + return &v1pb.ListVCSConnectorsResponse{ + VcsConnectors: connectors, + }, nil } // GetVCSConnector gets the vcs connector by id. -func (*mockClient) GetVCSConnector(_ context.Context, _ string) (*v1pb.VCSConnector, error) { - return nil, nil +func (c *mockClient) GetVCSConnector(_ context.Context, connectorName string) (*v1pb.VCSConnector, error) { + connector, ok := c.vcsConnectorMap[connectorName] + if !ok { + return nil, errors.Errorf("Cannot found connector %s", connectorName) + } + + return connector, nil } // CreateVCSConnector creates the vcs connector in a project. -func (*mockClient) CreateVCSConnector(_ context.Context, _, _ string, _ *v1pb.VCSConnector) (*v1pb.VCSConnector, error) { - return nil, nil +func (c *mockClient) CreateVCSConnector(_ context.Context, projectName, connectorID string, connector *v1pb.VCSConnector) (*v1pb.VCSConnector, error) { + connectorName := fmt.Sprintf("%s/%s%s", projectName, VCSProviderNamePrefix, connectorID) + c.vcsConnectorMap[connectorName] = connector + return connector, nil } // UpdateVCSConnector updates the vcs connector. -func (*mockClient) UpdateVCSConnector(_ context.Context, _ *v1pb.VCSConnector, _ []string) (*v1pb.VCSConnector, error) { - return nil, nil +func (c *mockClient) UpdateVCSConnector(ctx context.Context, connector *v1pb.VCSConnector, updateMasks []string) (*v1pb.VCSConnector, error) { + existed, err := c.GetVCSConnector(ctx, connector.Name) + if err != nil { + return nil, err + } + if slices.Contains(updateMasks, "branch") { + existed.Branch = connector.Branch + } + if slices.Contains(updateMasks, "base_directory") { + existed.BaseDirectory = connector.BaseDirectory + } + if slices.Contains(updateMasks, "database_group") { + existed.DatabaseGroup = connector.DatabaseGroup + } + c.vcsConnectorMap[connector.Name] = existed + return c.vcsConnectorMap[connector.Name], nil } // DeleteVCSConnector deletes the vcs provider. -func (*mockClient) DeleteVCSConnector(_ context.Context, _ string) error { +func (c *mockClient) DeleteVCSConnector(_ context.Context, connectorName string) error { + delete(c.vcsConnectorMap, connectorName) return nil } // ListUser list all users. -func (*mockClient) ListUser(_ context.Context, _ bool) (*v1pb.ListUsersResponse, error) { - return nil, nil +func (c *mockClient) ListUser(_ context.Context, showDeleted bool) (*v1pb.ListUsersResponse, error) { + users := make([]*v1pb.User, 0) + for _, user := range c.userMap { + if user.State == v1pb.State_DELETED && !showDeleted { + continue + } + users = append(users, user) + } + + return &v1pb.ListUsersResponse{ + Users: users, + }, nil } // GetUser gets the user by name. -func (*mockClient) GetUser(_ context.Context, _ string) (*v1pb.User, error) { - return nil, nil +func (c *mockClient) GetUser(_ context.Context, userName string) (*v1pb.User, error) { + user, ok := c.userMap[userName] + if !ok { + return nil, errors.Errorf("Cannot found user %s", userName) + } + + return user, nil } // CreateUser creates the user. -func (*mockClient) CreateUser(_ context.Context, _ *v1pb.User) (*v1pb.User, error) { - return nil, nil +func (c *mockClient) CreateUser(_ context.Context, user *v1pb.User) (*v1pb.User, error) { + c.userMap[user.Name] = user + return c.userMap[user.Name], nil } // UpdateUser updates the user. -func (*mockClient) UpdateUser(_ context.Context, _ *v1pb.User, _ []string) (*v1pb.User, error) { - return nil, nil +func (c *mockClient) UpdateUser(ctx context.Context, user *v1pb.User, updateMasks []string) (*v1pb.User, error) { + existed, err := c.GetUser(ctx, user.Name) + if err != nil { + return nil, err + } + if slices.Contains(updateMasks, "email") { + existed.Email = user.Email + existed.Name = fmt.Sprintf("%s%s", UserNamePrefix, user.Email) + } + if slices.Contains(updateMasks, "title") { + existed.Title = user.Title + } + if slices.Contains(updateMasks, "password") { + existed.Password = user.Password + } + if slices.Contains(updateMasks, "phone") { + existed.Phone = user.Phone + } + c.userMap[user.Name] = existed + return c.userMap[user.Name], nil } // DeleteUser deletes the user by name. -func (*mockClient) DeleteUser(_ context.Context, _ string) error { +func (c *mockClient) DeleteUser(ctx context.Context, userName string) error { + user, err := c.GetUser(ctx, userName) + if err != nil { + return err + } + + user.State = v1pb.State_DELETED + c.userMap[user.Name] = user + return nil } // UndeleteUser undeletes the user by name. -func (*mockClient) UndeleteUser(_ context.Context, _ string) (*v1pb.User, error) { - return nil, nil +func (c *mockClient) UndeleteUser(ctx context.Context, userName string) (*v1pb.User, error) { + user, err := c.GetUser(ctx, userName) + if err != nil { + return nil, err + } + + user.State = v1pb.State_ACTIVE + c.userMap[user.Name] = user + + return c.userMap[user.Name], nil +} + +// GetWorkspaceIAMPolicy gets the workspace IAM policy. +func (c *mockClient) GetWorkspaceIAMPolicy(_ context.Context) (*v1pb.IamPolicy, error) { + return c.workspaceIAMPolicy, nil +} + +// SetWorkspaceIAMPolicy sets the workspace IAM policy. +func (c *mockClient) SetWorkspaceIAMPolicy(_ context.Context, update *v1pb.SetIamPolicyRequest) (*v1pb.IamPolicy, error) { + if v := update.Policy; v != nil { + c.workspaceIAMPolicy = v + } + return c.workspaceIAMPolicy, nil } diff --git a/provider/resource_project.go b/provider/resource_project.go index 49a9664..7e3edb4 100644 --- a/provider/resource_project.go +++ b/provider/resource_project.go @@ -445,7 +445,7 @@ func updateMembersInProject(ctx context.Context, d *schema.ResourceData, client if tables, ok := rawCondition["tables"].(*schema.Set); ok && tables.Len() > 0 { tableList := []string{} for _, table := range tables.List() { - tableList = append(tableList, fmt.Sprintf(`"%s"`, table)) + tableList = append(tableList, fmt.Sprintf(`"%s"`, table.(string))) } expressions = append(expressions, fmt.Sprintf(`resource.table in [%s]`, strings.Join(tableList, ","))) } @@ -495,8 +495,11 @@ func updateMembersInProject(ctx context.Context, d *schema.ResourceData, client } if len(iamPolicy.Bindings) > 0 { - if _, err := client.SetProjectIAMPolicy(ctx, projectName, iamPolicy); err != nil { - return diag.Errorf("failed to update iam: %v", err.Error()) + if _, err := client.SetProjectIAMPolicy(ctx, projectName, &v1pb.SetIamPolicyRequest{ + Policy: iamPolicy, + Etag: iamPolicy.Etag, + }); err != nil { + return diag.Errorf("failed to update iam for project %s with error: %v", projectName, err.Error()) } } return nil diff --git a/provider/resource_user.go b/provider/resource_user.go index c06eb12..63a2550 100644 --- a/provider/resource_user.go +++ b/provider/resource_user.go @@ -3,12 +3,14 @@ package provider import ( "context" "fmt" + "slices" v1pb "github.com/bytebase/bytebase/proto/generated-go/v1" "github.com/hashicorp/terraform-plugin-log/tflog" "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/pkg/errors" "github.com/bytebase/terraform-provider-bytebase/api" "github.com/bytebase/terraform-provider-bytebase/provider/internal" @@ -48,17 +50,18 @@ func resourceUser() *schema.Resource { Optional: true, Description: "The user login password.", }, + "roles": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + Description: "The user's roles in the workspace level", + }, "type": { - Type: schema.TypeString, - // TODO: support set type. - Computed: true, - // Optional: true, - // Default: v1pb.UserType_USER.String(), + Type: schema.TypeString, + Computed: true, Description: "The user type.", - // ValidateFunc: validation.StringInSlice([]string{ - // v1pb.UserType_USER.String(), - // v1pb.UserType_SERVICE_ACCOUNT.String(), - // }, false), }, "name": { Type: schema.TypeString, @@ -108,7 +111,7 @@ func resourceUserRead(ctx context.Context, d *schema.ResourceData, m interface{} return diag.FromErr(err) } - return setUser(d, user) + return setUser(ctx, c, d, user) } func resourceUserDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { @@ -214,6 +217,15 @@ func resourceUserCreate(ctx context.Context, d *schema.ResourceData, m interface d.SetId(user.Name) } + if err := patchWorkspaceIAMPolicy(ctx, c, email, getRoles(d)); err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Failed to patch user roles", + Detail: fmt.Sprintf("Update roles for user %s failed, error: %v", userName, err), + }) + return diags + } + diag := resourceUserRead(ctx, d, m) if diag != nil { diags = append(diags, diag...) @@ -291,6 +303,17 @@ func resourceUserUpdate(ctx context.Context, d *schema.ResourceData, m interface } } + if d.HasChange("roles") { + if err := patchWorkspaceIAMPolicy(ctx, c, email, getRoles(d)); err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Failed to patch user roles", + Detail: fmt.Sprintf("Update roles for user %s failed, error: %v", userName, err), + }) + return diags + } + } + diag := resourceUserRead(ctx, d, m) if diag != nil { diags = append(diags, diag...) @@ -298,3 +321,58 @@ func resourceUserUpdate(ctx context.Context, d *schema.ResourceData, m interface return diags } + +func getRoles(d *schema.ResourceData) []string { + rawRoles := d.Get("roles").(*schema.Set) + roleList := []string{} + + for _, rawRole := range rawRoles.List() { + roleList = append(roleList, rawRole.(string)) + } + return roleList +} + +func patchWorkspaceIAMPolicy(ctx context.Context, client api.Client, email string, roles []string) error { + workspaceIamPolicy, err := client.GetWorkspaceIAMPolicy(ctx) + if err != nil { + return errors.Errorf("cannot get workspace IAM with error: %s", err.Error()) + } + patchMember := fmt.Sprintf("user:%s", email) + roleMap := map[string]bool{} + for _, role := range roles { + roleMap[role] = true + } + + for _, binding := range workspaceIamPolicy.Bindings { + index := slices.Index(binding.Members, patchMember) + if !roleMap[binding.Role] { + if index >= 0 { + binding.Members = slices.Delete(binding.Members, index, index+1) + } + } else { + if index < 0 { + binding.Members = append(binding.Members, patchMember) + } + } + + delete(roleMap, binding.Role) + } + + for role := range roleMap { + workspaceIamPolicy.Bindings = append(workspaceIamPolicy.Bindings, &v1pb.Binding{ + Role: role, + Members: []string{ + patchMember, + }, + }) + } + + if _, err := client.SetWorkspaceIAMPolicy(ctx, &v1pb.SetIamPolicyRequest{ + Policy: workspaceIamPolicy, + Etag: workspaceIamPolicy.Etag, + }); err != nil { + return err + } + + return nil +} From e81b7a1366c1992d0f11bc652807bc66a2435745 Mon Sep 17 00:00:00 2001 From: ecmadao Date: Wed, 11 Dec 2024 18:53:52 +0800 Subject: [PATCH 2/9] fix: lint --- provider/internal/mock_client.go | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/provider/internal/mock_client.go b/provider/internal/mock_client.go index dd184da..82e0409 100644 --- a/provider/internal/mock_client.go +++ b/provider/internal/mock_client.go @@ -492,8 +492,8 @@ func (c *mockClient) UndeleteProject(ctx context.Context, projectName string) (* } // GetProjectIAMPolicy gets the project IAM policy by project full name. -func (m *mockClient) GetProjectIAMPolicy(_ context.Context, projectName string) (*v1pb.IamPolicy, error) { - iamPolicy, ok := m.projectIAMMap[projectName] +func (c *mockClient) GetProjectIAMPolicy(_ context.Context, projectName string) (*v1pb.IamPolicy, error) { + iamPolicy, ok := c.projectIAMMap[projectName] if !ok { return &v1pb.IamPolicy{}, nil } @@ -501,9 +501,9 @@ func (m *mockClient) GetProjectIAMPolicy(_ context.Context, projectName string) } // SetProjectIAMPolicy sets the project IAM policy. -func (m *mockClient) SetProjectIAMPolicy(_ context.Context, projectName string, update *v1pb.SetIamPolicyRequest) (*v1pb.IamPolicy, error) { - m.projectIAMMap[projectName] = update.Policy - return m.projectIAMMap[projectName], nil +func (c *mockClient) SetProjectIAMPolicy(_ context.Context, projectName string, update *v1pb.SetIamPolicyRequest) (*v1pb.IamPolicy, error) { + c.projectIAMMap[projectName] = update.Policy + return c.projectIAMMap[projectName], nil } // ListSettings lists all settings. @@ -570,6 +570,7 @@ func (c *mockClient) GetVCSProvider(_ context.Context, providerName string) (*v1 // CreateVCSProvider creates the vcs provider. func (c *mockClient) CreateVCSProvider(_ context.Context, providerID string, provider *v1pb.VCSProvider) (*v1pb.VCSProvider, error) { providerName := fmt.Sprintf("%s%s", VCSProviderNamePrefix, providerID) + provider.Name = providerName c.vcsProviderMap[providerName] = provider return provider, nil } @@ -623,6 +624,7 @@ func (c *mockClient) GetVCSConnector(_ context.Context, connectorName string) (* // CreateVCSConnector creates the vcs connector in a project. func (c *mockClient) CreateVCSConnector(_ context.Context, projectName, connectorID string, connector *v1pb.VCSConnector) (*v1pb.VCSConnector, error) { connectorName := fmt.Sprintf("%s/%s%s", projectName, VCSProviderNamePrefix, connectorID) + connector.Name = connectorName c.vcsConnectorMap[connectorName] = connector return connector, nil } From 6d1758a7ee5b9587787a6ba57346e441ae11f78c Mon Sep 17 00:00:00 2001 From: ecmadao Date: Wed, 11 Dec 2024 19:02:21 +0800 Subject: [PATCH 3/9] fix: test --- examples/setup/main.tf | 9 ++++----- provider/resource_project.go | 8 +------- provider/resource_project_test.go | 5 +++++ 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/examples/setup/main.tf b/examples/setup/main.tf index 3f58a6a..7709d6a 100644 --- a/examples/setup/main.tf +++ b/examples/setup/main.tf @@ -110,13 +110,14 @@ resource "bytebase_user" "project_developer" { title = "Developer" email = "developer@bytebase.com" - # Grant workspace level roles, will grant projectViewer for this user in all projects. + # Grant workspace level roles, will grant projectViewer for this user in all roles = ["roles/projectViewer"] } # Create a new project resource "bytebase_project" "sample_project" { depends_on = [ + bytebase_user.workspace_dba, bytebase_user.project_developer ] @@ -125,8 +126,8 @@ resource "bytebase_project" "sample_project" { key = "SAMM" members { - member = format("user:%s", bytebase_user.project_developer.email) - role = "roles/projectDeveloper" + member = format("user:%s", bytebase_user.workspace_dba.email) + role = "roles/projectOwner" } members { @@ -270,5 +271,3 @@ resource "bytebase_vcs_connector" "github" { repository_branch = "main" repository_url = "https://github.com/ed-bytebase/gitops" } - - diff --git a/provider/resource_project.go b/provider/resource_project.go index 7e3edb4..21a75f3 100644 --- a/provider/resource_project.go +++ b/provider/resource_project.go @@ -485,13 +485,7 @@ func updateMembersInProject(ctx context.Context, d *schema.ResourceData, client } if !existProjectOwner { - // Make sure we have the project owner. - iamPolicy.Bindings = append(iamPolicy.Bindings, &v1pb.Binding{ - Role: "roles/projectOwner", - Members: []string{ - fmt.Sprintf("user:%s", client.GetCaller().Email), - }, - }) + return diag.Errorf("require at least 1 member with roles/projectOwner role") } if len(iamPolicy.Bindings) > 0 { diff --git a/provider/resource_project_test.go b/provider/resource_project_test.go index fb97d0c..f984535 100644 --- a/provider/resource_project_test.go +++ b/provider/resource_project_test.go @@ -76,6 +76,11 @@ func testAccCheckProjectResource(identifier, resourceID, title, key string) stri resource_id = "%s" title = "%s" key = "%s" + + members { + member = "user:mock@bytebase.com" + role = "roles/projectOwner" + } } `, identifier, resourceID, title, key) } From 5e636f5a2a89a20dcb91f0b5944372175150a339 Mon Sep 17 00:00:00 2001 From: ecmadao Date: Thu, 12 Dec 2024 11:14:16 +0800 Subject: [PATCH 4/9] chore: support group --- VERSION | 2 +- api/client.go | 12 ++ client/group.go | 92 +++++++++ docs/data-sources/group.md | 40 ++++ docs/data-sources/group_list.md | 44 +++++ docs/data-sources/instance.md | 3 + docs/data-sources/instance_list.md | 3 + docs/data-sources/user.md | 1 + docs/data-sources/user_list.md | 1 + docs/resources/group.md | 44 +++++ docs/resources/instance.md | 3 + docs/resources/user.md | 3 +- examples/groups/main.tf | 33 ++++ examples/policies/main.tf | 2 +- examples/settings/main.tf | 2 +- examples/setup/main.tf | 30 ++- examples/users/main.tf | 2 +- examples/vcs/main.tf | 2 +- provider/data_source_group.go | 124 ++++++++++++ provider/data_source_group_list.go | 118 +++++++++++ provider/data_source_instance.go | 15 ++ provider/data_source_instance_list.go | 21 ++ provider/internal/mock_client.go | 59 ++++++ provider/internal/utils.go | 2 + provider/provider.go | 3 + provider/resource_group.go | 273 ++++++++++++++++++++++++++ provider/resource_instance.go | 54 +++++ provider/resource_user.go | 9 +- 28 files changed, 982 insertions(+), 15 deletions(-) create mode 100644 client/group.go create mode 100644 docs/data-sources/group.md create mode 100644 docs/data-sources/group_list.md create mode 100644 docs/resources/group.md create mode 100644 examples/groups/main.tf create mode 100644 provider/data_source_group.go create mode 100644 provider/data_source_group_list.go create mode 100644 provider/resource_group.go diff --git a/VERSION b/VERSION index a6a3a43..1464c52 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.4 \ No newline at end of file +1.0.5 \ No newline at end of file diff --git a/api/client.go b/api/client.go index 22068e1..bb8a57c 100644 --- a/api/client.go +++ b/api/client.go @@ -128,6 +128,18 @@ type Client interface { // UndeleteUser undeletes the user by name. UndeleteUser(ctx context.Context, userName string) (*v1pb.User, error) + // Group + // ListGroup list all groups. + ListGroup(ctx context.Context) (*v1pb.ListGroupsResponse, error) + // CreateGroup creates the group. + CreateGroup(ctx context.Context, email string, group *v1pb.Group) (*v1pb.Group, error) + // GetGroup gets the group by name. + GetGroup(ctx context.Context, name string) (*v1pb.Group, error) + // UpdateGroup updates the group. + UpdateGroup(ctx context.Context, patch *v1pb.Group, updateMasks []string) (*v1pb.Group, error) + // DeleteGroup deletes the group by name. + DeleteGroup(ctx context.Context, name string) error + // Workspace // GetWorkspaceIAMPolicy gets the workspace IAM policy. GetWorkspaceIAMPolicy(ctx context.Context) (*v1pb.IamPolicy, error) diff --git a/client/group.go b/client/group.go new file mode 100644 index 0000000..2c63172 --- /dev/null +++ b/client/group.go @@ -0,0 +1,92 @@ +package client + +import ( + "context" + "fmt" + "net/http" + "strings" + + v1pb "github.com/bytebase/bytebase/proto/generated-go/v1" + "google.golang.org/protobuf/encoding/protojson" +) + +// ListGroup list all groups. +func (c *client) ListGroup(ctx context.Context) (*v1pb.ListGroupsResponse, error) { + req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/%s/groups", c.url, c.version), nil) + if err != nil { + return nil, err + } + + body, err := c.doRequest(req) + if err != nil { + return nil, err + } + + var res v1pb.ListGroupsResponse + if err := ProtojsonUnmarshaler.Unmarshal(body, &res); err != nil { + return nil, err + } + + return &res, nil +} + +// CreateGroup creates the group. +func (c *client) CreateGroup(ctx context.Context, email string, group *v1pb.Group) (*v1pb.Group, error) { + payload, err := protojson.Marshal(group) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("%s/%s/groups?groupEmail=%s", c.url, c.version, email), strings.NewReader(string(payload))) + + if err != nil { + return nil, err + } + + body, err := c.doRequest(req) + if err != nil { + return nil, err + } + + var res v1pb.Group + if err := ProtojsonUnmarshaler.Unmarshal(body, &res); err != nil { + return nil, err + } + + return &res, nil +} + +// GetGroup gets the group by name. +func (c *client) GetGroup(ctx context.Context, name string) (*v1pb.Group, error) { + body, err := c.getResource(ctx, name) + if err != nil { + return nil, err + } + + var res v1pb.Group + if err := ProtojsonUnmarshaler.Unmarshal(body, &res); err != nil { + return nil, err + } + + return &res, nil +} + +// UpdateGroup updates the group. +func (c *client) UpdateGroup(ctx context.Context, patch *v1pb.Group, updateMasks []string) (*v1pb.Group, error) { + body, err := c.updateResource(ctx, patch.Name, patch, updateMasks, false /* allow missing = false*/) + if err != nil { + return nil, err + } + + var res v1pb.Group + if err := ProtojsonUnmarshaler.Unmarshal(body, &res); err != nil { + return nil, err + } + + return &res, nil +} + +// DeleteGroup deletes the group by name. +func (c *client) DeleteGroup(ctx context.Context, name string) error { + return c.deleteResource(ctx, name) +} diff --git a/docs/data-sources/group.md b/docs/data-sources/group.md new file mode 100644 index 0000000..e3c354c --- /dev/null +++ b/docs/data-sources/group.md @@ -0,0 +1,40 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "bytebase_group Data Source - terraform-provider-bytebase" +subcategory: "" +description: |- + The group data source. +--- + +# bytebase_group (Data Source) + +The group data source. + + + + +## Schema + +### Required + +- `name` (String) The group name in groups/{email} format. + +### Read-Only + +- `create_time` (String) The group create time in YYYY-MM-DDThh:mm:ss.000Z format +- `creator` (String) The group creator in users/{email} format. +- `description` (String) The group description. +- `id` (String) The ID of this resource. +- `members` (Set of Object) The members in the group. (see [below for nested schema](#nestedatt--members)) +- `source` (String) Source means where the group comes from. For now we support Entra ID SCIM sync, so the source could be Entra ID. +- `title` (String) The group title. + + +### Nested Schema for `members` + +Read-Only: + +- `member` (String) +- `role` (String) + + diff --git a/docs/data-sources/group_list.md b/docs/data-sources/group_list.md new file mode 100644 index 0000000..4ef019d --- /dev/null +++ b/docs/data-sources/group_list.md @@ -0,0 +1,44 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "bytebase_group_list Data Source - terraform-provider-bytebase" +subcategory: "" +description: |- + The group data source list. +--- + +# bytebase_group_list (Data Source) + +The group data source list. + + + + +## Schema + +### Read-Only + +- `groups` (List of Object) (see [below for nested schema](#nestedatt--groups)) +- `id` (String) The ID of this resource. + + +### Nested Schema for `groups` + +Read-Only: + +- `create_time` (String) +- `creator` (String) +- `description` (String) +- `members` (Set of Object) (see [below for nested schema](#nestedobjatt--groups--members)) +- `name` (String) +- `source` (String) +- `title` (String) + + +### Nested Schema for `groups.members` + +Read-Only: + +- `member` (String) +- `role` (String) + + diff --git a/docs/data-sources/instance.md b/docs/data-sources/instance.md index e75e629..89ae596 100644 --- a/docs/data-sources/instance.md +++ b/docs/data-sources/instance.md @@ -23,10 +23,13 @@ The instance data source. - `data_sources` (List of Object) (see [below for nested schema](#nestedatt--data_sources)) - `engine` (String) The instance engine. Support MYSQL, POSTGRES, TIDB, SNOWFLAKE, CLICKHOUSE, MONGODB, SQLITE, REDIS, ORACLE, SPANNER, MSSQL, REDSHIFT, MARIADB, OCEANBASE. +- `engine_version` (String) The engine version. - `environment` (String) The environment name for your instance in "environments/{resource id}" format. - `external_link` (String) The external console URL managing this instance (e.g. AWS RDS console, your in-house DB instance console) - `id` (String) The ID of this resource. +- `maximum_connections` (Number) The maximum number of connections. The default value is 10. - `name` (String) The instance full name in instances/{resource id} format. +- `sync_interval` (Number) How often the instance is synced in seconds. Default 0, means never sync. - `title` (String) The instance title. diff --git a/docs/data-sources/instance_list.md b/docs/data-sources/instance_list.md index 4608bef..44d83e1 100644 --- a/docs/data-sources/instance_list.md +++ b/docs/data-sources/instance_list.md @@ -31,10 +31,13 @@ Read-Only: - `data_sources` (List of Object) (see [below for nested schema](#nestedobjatt--instances--data_sources)) - `engine` (String) +- `engine_version` (String) - `environment` (String) - `external_link` (String) +- `maximum_connections` (Number) - `name` (String) - `resource_id` (String) +- `sync_interval` (Number) - `title` (String) diff --git a/docs/data-sources/user.md b/docs/data-sources/user.md index 381d6e1..a51e7c7 100644 --- a/docs/data-sources/user.md +++ b/docs/data-sources/user.md @@ -27,6 +27,7 @@ The user data source. - `last_login_time` (String) The user last login time. - `mfa_enabled` (Boolean) The mfa_enabled flag means if the user has enabled MFA. - `phone` (String) The user phone. +- `roles` (Set of String) The user's roles in the workspace level - `source` (String) Source means where the user comes from. For now we support Entra ID SCIM sync, so the source could be Entra ID. - `state` (String) The user is deleted or not. - `title` (String) The user title. diff --git a/docs/data-sources/user_list.md b/docs/data-sources/user_list.md index 6a879dd..5c54403 100644 --- a/docs/data-sources/user_list.md +++ b/docs/data-sources/user_list.md @@ -35,6 +35,7 @@ Read-Only: - `mfa_enabled` (Boolean) - `name` (String) - `phone` (String) +- `roles` (Set of String) - `source` (String) - `state` (String) - `title` (String) diff --git a/docs/resources/group.md b/docs/resources/group.md new file mode 100644 index 0000000..79f4eca --- /dev/null +++ b/docs/resources/group.md @@ -0,0 +1,44 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "bytebase_group Resource - terraform-provider-bytebase" +subcategory: "" +description: |- + The group resource. +--- + +# bytebase_group (Resource) + +The group resource. + + + + +## Schema + +### Required + +- `email` (String) The group email. +- `members` (Block Set, Min: 1) The members in the group. (see [below for nested schema](#nestedblock--members)) +- `title` (String) The group title. + +### Optional + +- `description` (String) The group description. + +### Read-Only + +- `create_time` (String) The group create time in YYYY-MM-DDThh:mm:ss.000Z format +- `creator` (String) The group creator in users/{email} format. +- `id` (String) The ID of this resource. +- `name` (String) The group name in groups/{email} format. +- `source` (String) Source means where the group comes from. For now we support Entra ID SCIM sync, so the source could be Entra ID. + + +### Nested Schema for `members` + +Required: + +- `member` (String) The member in users/{email} format. +- `role` (String) The member's role in the group. + + diff --git a/docs/resources/instance.md b/docs/resources/instance.md index e0a052e..7c9c3f9 100644 --- a/docs/resources/instance.md +++ b/docs/resources/instance.md @@ -26,9 +26,12 @@ The instance resource. ### Optional - `external_link` (String) The external console URL managing this instance (e.g. AWS RDS console, your in-house DB instance console) +- `maximum_connections` (Number) The maximum number of connections. The default value is 10. +- `sync_interval` (Number) How often the instance is synced in seconds. Default 0, means never sync. ### Read-Only +- `engine_version` (String) The engine version. - `id` (String) The ID of this resource. - `name` (String) The instance full name in instances/{resource id} format. diff --git a/docs/resources/user.md b/docs/resources/user.md index e6545be..8cfe660 100644 --- a/docs/resources/user.md +++ b/docs/resources/user.md @@ -24,7 +24,7 @@ The user resource. - `password` (String, Sensitive) The user login password. - `phone` (String) The user phone. -- `state` (String) The user is deleted or not. +- `roles` (Set of String) The user's roles in the workspace level ### Read-Only @@ -34,6 +34,7 @@ The user resource. - `mfa_enabled` (Boolean) The mfa_enabled flag means if the user has enabled MFA. - `name` (String) The user name in users/{user id or email} format. - `source` (String) Source means where the user comes from. For now we support Entra ID SCIM sync, so the source could be Entra ID. +- `state` (String) The user is deleted or not. - `type` (String) The user type. diff --git a/examples/groups/main.tf b/examples/groups/main.tf new file mode 100644 index 0000000..b7fa47c --- /dev/null +++ b/examples/groups/main.tf @@ -0,0 +1,33 @@ +terraform { + required_providers { + bytebase = { + version = "1.0.5" + # For local development, please use "terraform.local/bytebase/bytebase" instead + source = "registry.terraform.io/bytebase/bytebase" + } + } +} + +provider "bytebase" { + # You need to replace the account and key with your Bytebase service account. + service_account = "terraform@service.bytebase.com" + service_key = "bbs_BxVIp7uQsARl8nR92ZZV" + # The Bytebase service URL. You can use the external URL in production. + # Check the docs about external URL: https://www.bytebase.com/docs/get-started/install/external-url + url = "https://bytebase.example.com" +} + +data "bytebase_group_list" "all" { +} + +output "all_groups" { + value = data.bytebase_group_list.all +} + +data "bytebase_group" "sample" { + name = "groups/group@bytebase.com" +} + +output "sample_group" { + value = data.bytebase_group.sample +} diff --git a/examples/policies/main.tf b/examples/policies/main.tf index fe07e4a..416cb5b 100644 --- a/examples/policies/main.tf +++ b/examples/policies/main.tf @@ -1,7 +1,7 @@ terraform { required_providers { bytebase = { - version = "1.0.4" + version = "1.0.5" # For local development, please use "terraform.local/bytebase/bytebase" instead source = "registry.terraform.io/bytebase/bytebase" } diff --git a/examples/settings/main.tf b/examples/settings/main.tf index d130a71..095fedc 100644 --- a/examples/settings/main.tf +++ b/examples/settings/main.tf @@ -1,7 +1,7 @@ terraform { required_providers { bytebase = { - version = "1.0.4" + version = "1.0.5" # For local development, please use "terraform.local/bytebase/bytebase" instead source = "registry.terraform.io/bytebase/bytebase" } diff --git a/examples/setup/main.tf b/examples/setup/main.tf index 7709d6a..b7cd87c 100644 --- a/examples/setup/main.tf +++ b/examples/setup/main.tf @@ -1,7 +1,7 @@ terraform { required_providers { bytebase = { - version = "1.0.4" + version = "1.0.5" # For local development, please use "terraform.local/bytebase/bytebase" instead source = "registry.terraform.io/bytebase/bytebase" } @@ -114,11 +114,32 @@ resource "bytebase_user" "project_developer" { roles = ["roles/projectViewer"] } +resource "bytebase_group" "developers" { + depends_on = [ + bytebase_user.workspace_dba, + bytebase_user.project_developer + ] + + email = "developers@bytebase.com" + title = "Bytebase Developers" + + members { + member = format("users/%s", bytebase_user.workspace_dba.email) + role = "OWNER" + } + + members { + member = format("users/%s", bytebase_user.project_developer.email) + role = "MEMBER" + } +} + # Create a new project resource "bytebase_project" "sample_project" { depends_on = [ bytebase_user.workspace_dba, - bytebase_user.project_developer + bytebase_user.project_developer, + bytebase_group.developers ] resource_id = local.project_id @@ -130,6 +151,11 @@ resource "bytebase_project" "sample_project" { role = "roles/projectOwner" } + members { + member = format("group:%s", bytebase_group.developers.email) + role = "roles/projectDeveloper" + } + members { member = format("user:%s", bytebase_user.project_developer.email) role = "roles/projectExporter" diff --git a/examples/users/main.tf b/examples/users/main.tf index 5ea0423..2baadf6 100644 --- a/examples/users/main.tf +++ b/examples/users/main.tf @@ -1,7 +1,7 @@ terraform { required_providers { bytebase = { - version = "1.0.4" + version = "1.0.5" # For local development, please use "terraform.local/bytebase/bytebase" instead source = "registry.terraform.io/bytebase/bytebase" } diff --git a/examples/vcs/main.tf b/examples/vcs/main.tf index 3e763b3..4e5b9fc 100644 --- a/examples/vcs/main.tf +++ b/examples/vcs/main.tf @@ -1,7 +1,7 @@ terraform { required_providers { bytebase = { - version = "1.0.4" + version = "1.0.5" # For local development, please use "terraform.local/bytebase/bytebase" instead source = "registry.terraform.io/bytebase/bytebase" } diff --git a/provider/data_source_group.go b/provider/data_source_group.go new file mode 100644 index 0000000..9dc5b01 --- /dev/null +++ b/provider/data_source_group.go @@ -0,0 +1,124 @@ +package provider + +import ( + "context" + "fmt" + "regexp" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + + v1pb "github.com/bytebase/bytebase/proto/generated-go/v1" + + "github.com/bytebase/terraform-provider-bytebase/api" + "github.com/bytebase/terraform-provider-bytebase/provider/internal" +) + +func dataSourceGroup() *schema.Resource { + return &schema.Resource{ + Description: "The group data source.", + ReadContext: dataSourceGroupRead, + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Required: true, + ValidateDiagFunc: internal.ResourceNameValidation( + regexp.MustCompile(fmt.Sprintf("^%s", internal.GroupNamePrefix)), + ), + Description: "The group name in groups/{email} format.", + }, + "title": { + Type: schema.TypeString, + Computed: true, + Description: "The group title.", + }, + "description": { + Type: schema.TypeString, + Computed: true, + Description: "The group description.", + }, + "source": { + Type: schema.TypeString, + Computed: true, + Description: "Source means where the group comes from. For now we support Entra ID SCIM sync, so the source could be Entra ID.", + }, + "creator": { + Type: schema.TypeString, + Computed: true, + Description: "The group creator in users/{email} format.", + }, + "create_time": { + Type: schema.TypeString, + Computed: true, + Description: "The group create time in YYYY-MM-DDThh:mm:ss.000Z format", + }, + "members": { + Type: schema.TypeSet, + Computed: true, + Description: "The members in the group.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "member": { + Type: schema.TypeString, + Computed: true, + Description: "The member in users/{email} format.", + }, + "role": { + Type: schema.TypeString, + Computed: true, + Description: "The member's role in the group.", + }, + }, + }, + Set: memberHash, + }, + }, + } +} + +func dataSourceGroupRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + c := m.(api.Client) + groupName := d.Get("name").(string) + group, err := c.GetGroup(ctx, groupName) + if err != nil { + return diag.FromErr(err) + } + + d.SetId(group.Name) + return setGroup(d, group) +} + +func setGroup(d *schema.ResourceData, group *v1pb.Group) diag.Diagnostics { + if err := d.Set("name", group.Name); err != nil { + return diag.Errorf("cannot set name for group: %s", err.Error()) + } + if err := d.Set("title", group.Title); err != nil { + return diag.Errorf("cannot set title for group: %s", err.Error()) + } + if err := d.Set("description", group.Description); err != nil { + return diag.Errorf("cannot set description for group: %s", err.Error()) + } + if err := d.Set("creator", group.Creator); err != nil { + return diag.Errorf("cannot set creator for group: %s", err.Error()) + } + if err := d.Set("create_time", group.CreateTime.AsTime().UTC().Format(time.RFC3339)); err != nil { + return diag.Errorf("cannot set create_time for group: %s", err.Error()) + } + if err := d.Set("source", group.Source); err != nil { + return diag.Errorf("cannot set source for group: %s", err.Error()) + } + + memberList := []interface{}{} + for _, member := range group.Members { + rawMember := map[string]interface{}{} + rawMember["member"] = member.Member + rawMember["role"] = member.Role.String() + memberList = append(memberList, rawMember) + } + if err := d.Set("members", schema.NewSet(memberHash, memberList)); err != nil { + return diag.Errorf("cannot set members for group: %s", err.Error()) + } + + return nil +} diff --git a/provider/data_source_group_list.go b/provider/data_source_group_list.go new file mode 100644 index 0000000..09fa073 --- /dev/null +++ b/provider/data_source_group_list.go @@ -0,0 +1,118 @@ +package provider + +import ( + "context" + "strconv" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + + "github.com/bytebase/terraform-provider-bytebase/api" +) + +func dataSourceGroupList() *schema.Resource { + return &schema.Resource{ + Description: "The group data source list.", + ReadContext: dataSourceGroupListRead, + Schema: map[string]*schema.Schema{ + "groups": { + Type: schema.TypeList, + Computed: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Computed: true, + Description: "The group name in groups/{email} format.", + }, + "title": { + Type: schema.TypeString, + Computed: true, + Description: "The group title.", + }, + "description": { + Type: schema.TypeString, + Computed: true, + Description: "The group description.", + }, + "source": { + Type: schema.TypeString, + Computed: true, + Description: "Source means where the group comes from. For now we support Entra ID SCIM sync, so the source could be Entra ID.", + }, + "creator": { + Type: schema.TypeString, + Computed: true, + Description: "The group creator in users/{email} format.", + }, + "create_time": { + Type: schema.TypeString, + Computed: true, + Description: "The group create time in YYYY-MM-DDThh:mm:ss.000Z format", + }, + "members": { + Type: schema.TypeSet, + Computed: true, + Description: "The members in the group.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "member": { + Type: schema.TypeString, + Computed: true, + Description: "The member in users/{email} format.", + }, + "role": { + Type: schema.TypeString, + Computed: true, + Description: "The member's role in the group.", + }, + }, + }, + Set: memberHash, + }, + }, + }, + }, + }, + } +} + +func dataSourceGroupListRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + c := m.(api.Client) + + response, err := c.ListGroup(ctx) + if err != nil { + return diag.FromErr(err) + } + + groups := make([]map[string]interface{}, 0) + for _, group := range response.Groups { + raw := make(map[string]interface{}) + raw["name"] = group.Name + raw["title"] = group.Title + raw["description"] = group.Description + raw["creator"] = group.Creator + raw["create_time"] = group.CreateTime.AsTime().UTC().Format(time.RFC3339) + raw["source"] = group.Source + + memberList := []interface{}{} + for _, member := range group.Members { + rawMember := map[string]interface{}{} + rawMember["member"] = member.Member + rawMember["role"] = member.Role.String() + memberList = append(memberList, rawMember) + } + raw["members"] = schema.NewSet(memberHash, memberList) + groups = append(groups, raw) + } + + if err := d.Set("groups", groups); err != nil { + return diag.FromErr(err) + } + + // always refresh + d.SetId(strconv.FormatInt(time.Now().Unix(), 10)) + + return nil +} diff --git a/provider/data_source_instance.go b/provider/data_source_instance.go index ce6a005..bd14105 100644 --- a/provider/data_source_instance.go +++ b/provider/data_source_instance.go @@ -42,11 +42,26 @@ func dataSourceInstance() *schema.Resource { Computed: true, Description: "The instance engine. Support MYSQL, POSTGRES, TIDB, SNOWFLAKE, CLICKHOUSE, MONGODB, SQLITE, REDIS, ORACLE, SPANNER, MSSQL, REDSHIFT, MARIADB, OCEANBASE.", }, + "engine_version": { + Type: schema.TypeString, + Computed: true, + Description: "The engine version.", + }, "external_link": { Type: schema.TypeString, Computed: true, Description: "The external console URL managing this instance (e.g. AWS RDS console, your in-house DB instance console)", }, + "sync_interval": { + Type: schema.TypeInt, + Computed: true, + Description: "How often the instance is synced in seconds. Default 0, means never sync.", + }, + "maximum_connections": { + Type: schema.TypeInt, + Computed: true, + Description: "The maximum number of connections. The default value is 10.", + }, "data_sources": { Type: schema.TypeList, Computed: true, diff --git a/provider/data_source_instance_list.go b/provider/data_source_instance_list.go index 8961904..90e3385 100644 --- a/provider/data_source_instance_list.go +++ b/provider/data_source_instance_list.go @@ -53,11 +53,26 @@ func dataSourceInstanceList() *schema.Resource { Computed: true, Description: "The instance engine. Support MYSQL, POSTGRES, TIDB, SNOWFLAKE, CLICKHOUSE, MONGODB, SQLITE, REDIS, ORACLE, SPANNER, MSSQL, REDSHIFT, MARIADB, OCEANBASE.", }, + "engine_version": { + Type: schema.TypeString, + Computed: true, + Description: "The engine version.", + }, "external_link": { Type: schema.TypeString, Computed: true, Description: "The external console URL managing this instance (e.g. AWS RDS console, your in-house DB instance console)", }, + "sync_interval": { + Type: schema.TypeInt, + Computed: true, + Description: "How often the instance is synced in seconds. Default 0, means never sync.", + }, + "maximum_connections": { + Type: schema.TypeInt, + Computed: true, + Description: "The maximum number of connections. The default value is 10.", + }, "data_sources": { Type: schema.TypeList, Computed: true, @@ -150,9 +165,15 @@ func dataSourceInstanceListRead(ctx context.Context, d *schema.ResourceData, m i ins["title"] = instance.Title ins["name"] = instance.Name ins["engine"] = instance.Engine.String() + ins["engine_version"] = instance.EngineVersion ins["external_link"] = instance.ExternalLink ins["environment"] = instance.Environment + if op := instance.Options; op != nil { + ins["sync_interval"] = op.SyncInterval.GetSeconds() + ins["maximum_connections"] = op.MaximumConnections + } + dataSources, err := flattenDataSourceList(d, instance.DataSources) if err != nil { return diag.FromErr(err) diff --git a/provider/internal/mock_client.go b/provider/internal/mock_client.go index 82e0409..5355313 100644 --- a/provider/internal/mock_client.go +++ b/provider/internal/mock_client.go @@ -24,6 +24,7 @@ var settingMap map[string]*v1pb.Setting var vcsProviderMap map[string]*v1pb.VCSProvider var vcsConnectorMap map[string]*v1pb.VCSConnector var userMap map[string]*v1pb.User +var groupMap map[string]*v1pb.Group func init() { environmentMap = map[string]*v1pb.Environment{} @@ -36,6 +37,7 @@ func init() { vcsProviderMap = map[string]*v1pb.VCSProvider{} vcsConnectorMap = map[string]*v1pb.VCSConnector{} userMap = map[string]*v1pb.User{} + groupMap = map[string]*v1pb.Group{} } type mockClient struct { @@ -49,6 +51,7 @@ type mockClient struct { vcsProviderMap map[string]*v1pb.VCSProvider vcsConnectorMap map[string]*v1pb.VCSConnector userMap map[string]*v1pb.User + groupMap map[string]*v1pb.Group workspaceIAMPolicy *v1pb.IamPolicy } @@ -65,6 +68,7 @@ func newMockClient(_, _, _ string) (api.Client, error) { vcsProviderMap: vcsProviderMap, vcsConnectorMap: vcsConnectorMap, userMap: userMap, + groupMap: groupMap, workspaceIAMPolicy: &v1pb.IamPolicy{}, }, nil } @@ -734,6 +738,61 @@ func (c *mockClient) UndeleteUser(ctx context.Context, userName string) (*v1pb.U return c.userMap[user.Name], nil } +// ListGroup list all groups. +func (c *mockClient) ListGroup(_ context.Context) (*v1pb.ListGroupsResponse, error) { + groups := make([]*v1pb.Group, 0) + for _, group := range c.groupMap { + groups = append(groups, group) + } + + return &v1pb.ListGroupsResponse{ + Groups: groups, + }, nil +} + +// GetGroup gets the group by name. +func (c *mockClient) GetGroup(_ context.Context, name string) (*v1pb.Group, error) { + group, ok := c.groupMap[name] + if !ok { + return nil, errors.Errorf("Cannot found group %s", name) + } + + return group, nil +} + +// CreateGroup creates the group. +func (c *mockClient) CreateGroup(_ context.Context, email string, group *v1pb.Group) (*v1pb.Group, error) { + groupName := fmt.Sprintf("%s%s", GroupNamePrefix, email) + group.Name = groupName + c.groupMap[groupName] = group + return c.groupMap[groupName], nil +} + +// UpdateGroup updates the group. +func (c *mockClient) UpdateGroup(ctx context.Context, group *v1pb.Group, updateMasks []string) (*v1pb.Group, error) { + existed, err := c.GetGroup(ctx, group.Name) + if err != nil { + return nil, err + } + if slices.Contains(updateMasks, "description") { + existed.Description = group.Description + } + if slices.Contains(updateMasks, "title") { + existed.Title = group.Title + } + if slices.Contains(updateMasks, "members") { + existed.Members = group.Members + } + c.groupMap[existed.Name] = existed + return existed, nil +} + +// DeleteGroup deletes the group by name. +func (c *mockClient) DeleteGroup(ctx context.Context, name string) error { + delete(c.groupMap, name) + return nil +} + // GetWorkspaceIAMPolicy gets the workspace IAM policy. func (c *mockClient) GetWorkspaceIAMPolicy(_ context.Context) (*v1pb.IamPolicy, error) { return c.workspaceIAMPolicy, nil diff --git a/provider/internal/utils.go b/provider/internal/utils.go index becb7fe..ba466db 100644 --- a/provider/internal/utils.go +++ b/provider/internal/utils.go @@ -33,6 +33,8 @@ const ( VCSConnectorNamePrefix = "vcsConnectors/" // UserNamePrefix is the prefix for user name. UserNamePrefix = "users/" + // GroupNamePrefix is the prefix for group name. + GroupNamePrefix = "groups/" // RoleNamePrefix is the prefix for role name. RoleNamePrefix = "roles/" // ResourceIDPattern is the pattern for resource id. diff --git a/provider/provider.go b/provider/provider.go index 213db36..ed7a5fe 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -64,6 +64,8 @@ func NewProvider() *schema.Provider { "bytebase_vcs_connector_list": dataSourceVCSConnectorList(), "bytebase_user": dataSourceUser(), "bytebase_user_list": dataSourceUserList(), + "bytebase_group": dataSourceGroup(), + "bytebase_group_list": dataSourceGroupList(), }, ResourcesMap: map[string]*schema.Resource{ "bytebase_environment": resourceEnvironment(), @@ -74,6 +76,7 @@ func NewProvider() *schema.Provider { "bytebase_vcs_provider": resourceVCSProvider(), "bytebase_vcs_connector": resourceVCSConnector(), "bytebase_user": resourceUser(), + "bytebase_group": resourceGroup(), }, } } diff --git a/provider/resource_group.go b/provider/resource_group.go new file mode 100644 index 0000000..1056442 --- /dev/null +++ b/provider/resource_group.go @@ -0,0 +1,273 @@ +package provider + +import ( + "context" + "fmt" + "regexp" + + v1pb "github.com/bytebase/bytebase/proto/generated-go/v1" + "github.com/hashicorp/terraform-plugin-log/tflog" + "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/pkg/errors" + + "github.com/bytebase/terraform-provider-bytebase/api" + "github.com/bytebase/terraform-provider-bytebase/provider/internal" +) + +func resourceGroup() *schema.Resource { + return &schema.Resource{ + Description: "The group resource.", + ReadContext: resourceGroupRead, + DeleteContext: resourceGroupDelete, + CreateContext: resourceGroupCreate, + UpdateContext: resourceGroupUpdate, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + Schema: map[string]*schema.Schema{ + "email": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringIsNotEmpty, + Description: "The group email.", + }, + "title": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringIsNotEmpty, + Description: "The group title.", + }, + "description": { + Type: schema.TypeString, + Optional: true, + Description: "The group description.", + }, + "name": { + Type: schema.TypeString, + Computed: true, + Description: "The group name in groups/{email} format.", + }, + "source": { + Type: schema.TypeString, + Computed: true, + Description: "Source means where the group comes from. For now we support Entra ID SCIM sync, so the source could be Entra ID.", + }, + "creator": { + Type: schema.TypeString, + Computed: true, + Description: "The group creator in users/{email} format.", + }, + "create_time": { + Type: schema.TypeString, + Computed: true, + Description: "The group create time in YYYY-MM-DDThh:mm:ss.000Z format", + }, + "members": { + Type: schema.TypeSet, + Required: true, + MinItems: 1, + Description: "The members in the group.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "member": { + Type: schema.TypeString, + Required: true, + ValidateDiagFunc: internal.ResourceNameValidation( + regexp.MustCompile(fmt.Sprintf("^%s", internal.UserNamePrefix)), + ), + Description: "The member in users/{email} format.", + }, + "role": { + Type: schema.TypeString, + Required: true, + Description: "The member's role in the group.", + ValidateFunc: validation.StringInSlice([]string{ + v1pb.GroupMember_OWNER.String(), + v1pb.GroupMember_MEMBER.String(), + }, false), + }, + }, + }, + Set: memberHash, + }, + }, + } +} + +func resourceGroupRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + c := m.(api.Client) + + fullName := d.Id() + group, err := c.GetGroup(ctx, fullName) + if err != nil { + return diag.FromErr(err) + } + + return setGroup(d, group) +} + +func resourceGroupDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + c := m.(api.Client) + fullName := d.Id() + + // Warning or errors can be collected in a slice type + var diags diag.Diagnostics + + if err := c.DeleteGroup(ctx, fullName); err != nil { + return diag.FromErr(err) + } + + d.SetId("") + + return diags +} + +func resourceGroupCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + c := m.(api.Client) + groupEmail := d.Get("email").(string) + groupName := fmt.Sprintf("%s%s", internal.GroupNamePrefix, groupEmail) + + title := d.Get("title").(string) + description := d.Get("description").(string) + members, err := convertToMemberList(d) + if err != nil { + return diag.FromErr(err) + } + + existedGroup, err := c.GetGroup(ctx, groupName) + if err != nil { + tflog.Debug(ctx, fmt.Sprintf("get group %s failed with error: %v", groupName, err)) + } + + var diags diag.Diagnostics + if existedGroup != nil && err == nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Warning, + Summary: "Group already exists", + Detail: fmt.Sprintf("Group %s already exists, try to exec the update operation", groupName), + }) + + updateMasks := []string{"members"} + if title != existedGroup.Title { + updateMasks = append(updateMasks, "title") + } + if description != existedGroup.Description { + updateMasks = append(updateMasks, "description") + } + + if _, err := c.UpdateGroup(ctx, &v1pb.Group{ + Name: groupName, + Title: title, + Description: description, + Members: members, + }, updateMasks); err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Failed to update group", + Detail: fmt.Sprintf("Update group %s failed, error: %v", groupName, err), + }) + return diags + } + } else { + if _, err := c.CreateGroup(ctx, groupEmail, &v1pb.Group{ + Name: groupName, + Title: title, + Description: description, + Members: members, + }); err != nil { + return diag.FromErr(err) + } + } + + d.SetId(groupName) + + diag := resourceGroupRead(ctx, d, m) + if diag != nil { + diags = append(diags, diag...) + } + + return diags +} + +func resourceGroupUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + if d.HasChange("email") { + return diag.Errorf("cannot change the group email") + } + + c := m.(api.Client) + groupName := d.Id() + + title := d.Get("title").(string) + description := d.Get("description").(string) + members, err := convertToMemberList(d) + if err != nil { + return diag.FromErr(err) + } + + updateMasks := []string{"members"} + if d.HasChange("title") { + updateMasks = append(updateMasks, "title") + } + if d.HasChange("description") { + updateMasks = append(updateMasks, "description") + } + if d.HasChange("members") { + updateMasks = append(updateMasks, "members") + } + + var diags diag.Diagnostics + if len(updateMasks) > 0 { + if _, err := c.UpdateGroup(ctx, &v1pb.Group{ + Name: groupName, + Title: title, + Description: description, + Members: members, + }, updateMasks); err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Failed to update group", + Detail: fmt.Sprintf("Update group %s failed, error: %v", groupName, err), + }) + return diags + } + } + + diag := resourceGroupRead(ctx, d, m) + if diag != nil { + diags = append(diags, diag...) + } + + return diags +} + +func convertToMemberList(d *schema.ResourceData) ([]*v1pb.GroupMember, error) { + memberSet, ok := d.Get("members").(*schema.Set) + if !ok { + return nil, errors.Errorf("group members is required") + } + + memberList := []*v1pb.GroupMember{} + existOwner := false + for _, m := range memberSet.List() { + rawMember := m.(map[string]interface{}) + + member := rawMember["member"].(string) + role := v1pb.GroupMember_Role(v1pb.GroupMember_Role_value[rawMember["role"].(string)]) + memberList = append(memberList, &v1pb.GroupMember{ + Member: member, + Role: role, + }) + + if role == v1pb.GroupMember_OWNER { + existOwner = true + } + } + + if !existOwner { + return nil, errors.Errorf("require at least 1 group owner") + } + + return memberList, nil +} diff --git a/provider/resource_instance.go b/provider/resource_instance.go index cbd2b91..700ddd5 100644 --- a/provider/resource_instance.go +++ b/provider/resource_instance.go @@ -10,6 +10,7 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" "github.com/pkg/errors" + "google.golang.org/protobuf/types/known/durationpb" v1pb "github.com/bytebase/bytebase/proto/generated-go/v1" @@ -79,12 +80,28 @@ func resourceInstance() *schema.Resource { }, false), Description: "The instance engine. Support MYSQL, POSTGRES, TIDB, SNOWFLAKE, CLICKHOUSE, MONGODB, SQLITE, REDIS, ORACLE, SPANNER, MSSQL, REDSHIFT, MARIADB, OCEANBASE.", }, + "engine_version": { + Type: schema.TypeString, + Computed: true, + Description: "The engine version.", + }, "external_link": { Type: schema.TypeString, Optional: true, Default: "", Description: "The external console URL managing this instance (e.g. AWS RDS console, your in-house DB instance console)", }, + "sync_interval": { + Type: schema.TypeInt, + Optional: true, + Description: "How often the instance is synced in seconds. Default 0, means never sync.", + }, + "maximum_connections": { + Type: schema.TypeInt, + Optional: true, + Default: 10, + Description: "The maximum number of connections. The default value is 10.", + }, "data_sources": { Type: schema.TypeList, Required: true, @@ -177,6 +194,12 @@ func resourceInstanceCreate(ctx context.Context, d *schema.ResourceData, m inter instanceName := fmt.Sprintf("%s%s", internal.InstanceNamePrefix, instanceID) title := d.Get("title").(string) externalLink := d.Get("external_link").(string) + instanceOptions := &v1pb.InstanceOptions{ + SyncInterval: &durationpb.Duration{ + Seconds: int64(d.Get("sync_interval").(int)), + }, + MaximumConnections: int32(d.Get("maximum_connections").(int)), + } engineString := d.Get("engine").(string) engineValue, ok := v1pb.Engine_value[engineString] @@ -230,6 +253,12 @@ func resourceInstanceCreate(ctx context.Context, d *schema.ResourceData, m inter if externalLink != "" && externalLink != existedInstance.ExternalLink { updateMasks = append(updateMasks, "external_link") } + if instanceOptions.MaximumConnections != existedInstance.Options.MaximumConnections { + updateMasks = append(updateMasks, "options.maximum_connections") + } + if instanceOptions.SyncInterval.GetSeconds() != existedInstance.Options.SyncInterval.GetSeconds() { + updateMasks = append(updateMasks, "options.sync_interval") + } if len(dataSourceList) > 0 { updateMasks = append(updateMasks, "data_sources") } @@ -241,6 +270,7 @@ func resourceInstanceCreate(ctx context.Context, d *schema.ResourceData, m inter ExternalLink: externalLink, DataSources: dataSourceList, State: v1pb.State_ACTIVE, + Options: instanceOptions, }, updateMasks); err != nil { diags = append(diags, diag.Diagnostic{ Severity: diag.Error, @@ -259,6 +289,7 @@ func resourceInstanceCreate(ctx context.Context, d *schema.ResourceData, m inter State: v1pb.State_ACTIVE, DataSources: dataSourceList, Environment: d.Get("environment").(string), + Options: instanceOptions, }); err != nil { return diag.FromErr(err) } @@ -345,6 +376,12 @@ func resourceInstanceUpdate(ctx context.Context, d *schema.ResourceData, m inter if d.HasChange("data_sources") { paths = append(paths, "data_sources") } + if d.HasChange("sync_interval") { + paths = append(paths, "options.sync_interval") + } + if d.HasChange("maximum_connections") { + paths = append(paths, "options.maximum_connections") + } if len(paths) > 0 { if _, err := c.UpdateInstance(ctx, &v1pb.Instance{ @@ -353,6 +390,12 @@ func resourceInstanceUpdate(ctx context.Context, d *schema.ResourceData, m inter ExternalLink: d.Get("external_link").(string), DataSources: dataSourceList, State: v1pb.State_ACTIVE, + Options: &v1pb.InstanceOptions{ + SyncInterval: &durationpb.Duration{ + Seconds: int64(d.Get("sync_interval").(int)), + }, + MaximumConnections: int32(d.Get("maximum_connections").(int)), + }, }, paths); err != nil { return diag.FromErr(err) } @@ -409,9 +452,20 @@ func setInstanceMessage(d *schema.ResourceData, instance *v1pb.Instance) diag.Di if err := d.Set("engine", instance.Engine.String()); err != nil { return diag.Errorf("cannot set engine for instance: %s", err.Error()) } + if err := d.Set("engine_version", instance.EngineVersion); err != nil { + return diag.Errorf("cannot set engine_version for instance: %s", err.Error()) + } if err := d.Set("external_link", instance.ExternalLink); err != nil { return diag.Errorf("cannot set external_link for instance: %s", err.Error()) } + if op := instance.Options; op != nil { + if err := d.Set("sync_interval", op.SyncInterval.GetSeconds()); err != nil { + return diag.Errorf("cannot set sync_interval for instance: %s", err.Error()) + } + if err := d.Set("maximum_connections", op.MaximumConnections); err != nil { + return diag.Errorf("cannot set maximum_connections for instance: %s", err.Error()) + } + } dataSources, err := flattenDataSourceList(d, instance.DataSources) if err != nil { diff --git a/provider/resource_user.go b/provider/resource_user.go index 63a2550..1020d75 100644 --- a/provider/resource_user.go +++ b/provider/resource_user.go @@ -74,13 +74,8 @@ func resourceUser() *schema.Resource { Description: "The mfa_enabled flag means if the user has enabled MFA.", }, "state": { - Type: schema.TypeString, - Optional: true, - Default: v1pb.State_ACTIVE.String(), - ValidateFunc: validation.StringInSlice([]string{ - v1pb.State_ACTIVE.String(), - v1pb.State_DELETED.String(), - }, false), + Type: schema.TypeString, + Computed: true, Description: "The user is deleted or not.", }, "last_login_time": { From 2281acc3b8e05a40257e285b64dce84667dd5cd4 Mon Sep 17 00:00:00 2001 From: ecmadao Date: Thu, 12 Dec 2024 11:19:35 +0800 Subject: [PATCH 5/9] fix: test --- provider/internal/mock_client.go | 9 ++++++++- provider/resource_instance.go | 12 +++++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/provider/internal/mock_client.go b/provider/internal/mock_client.go index 5355313..3c28658 100644 --- a/provider/internal/mock_client.go +++ b/provider/internal/mock_client.go @@ -206,6 +206,7 @@ func (c *mockClient) CreateInstance(_ context.Context, instanceID string, instan ExternalLink: instance.ExternalLink, DataSources: instance.DataSources, Environment: instance.Environment, + Options: &v1pb.InstanceOptions{}, } envID, err := GetEnvironmentID(ins.Environment) @@ -242,6 +243,12 @@ func (c *mockClient) UpdateInstance(ctx context.Context, patch *v1pb.Instance, u if slices.Contains(updateMasks, "data_sources") { ins.DataSources = patch.DataSources } + if slices.Contains(updateMasks, "options.sync_interval") { + ins.Options.SyncInterval = patch.Options.SyncInterval + } + if slices.Contains(updateMasks, "options.maximum_connections") { + ins.Options.MaximumConnections = patch.Options.MaximumConnections + } c.instanceMap[ins.Name] = ins return ins, nil @@ -788,7 +795,7 @@ func (c *mockClient) UpdateGroup(ctx context.Context, group *v1pb.Group, updateM } // DeleteGroup deletes the group by name. -func (c *mockClient) DeleteGroup(ctx context.Context, name string) error { +func (c *mockClient) DeleteGroup(_ context.Context, name string) error { delete(c.groupMap, name) return nil } diff --git a/provider/resource_instance.go b/provider/resource_instance.go index 700ddd5..cf24f32 100644 --- a/provider/resource_instance.go +++ b/provider/resource_instance.go @@ -253,11 +253,13 @@ func resourceInstanceCreate(ctx context.Context, d *schema.ResourceData, m inter if externalLink != "" && externalLink != existedInstance.ExternalLink { updateMasks = append(updateMasks, "external_link") } - if instanceOptions.MaximumConnections != existedInstance.Options.MaximumConnections { - updateMasks = append(updateMasks, "options.maximum_connections") - } - if instanceOptions.SyncInterval.GetSeconds() != existedInstance.Options.SyncInterval.GetSeconds() { - updateMasks = append(updateMasks, "options.sync_interval") + if op := existedInstance.Options; op != nil { + if instanceOptions.SyncInterval.GetSeconds() != op.SyncInterval.GetSeconds() { + updateMasks = append(updateMasks, "options.sync_interval") + } + if instanceOptions.MaximumConnections != op.MaximumConnections { + updateMasks = append(updateMasks, "options.maximum_connections") + } } if len(dataSourceList) > 0 { updateMasks = append(updateMasks, "data_sources") From d37b4eee2d44e2a031caeede91b1b8b0e8e34824 Mon Sep 17 00:00:00 2001 From: ecmadao Date: Thu, 12 Dec 2024 11:26:25 +0800 Subject: [PATCH 6/9] fix: test --- docs/resources/instance.md | 2 +- provider/resource_instance.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/resources/instance.md b/docs/resources/instance.md index 7c9c3f9..a10b62b 100644 --- a/docs/resources/instance.md +++ b/docs/resources/instance.md @@ -26,7 +26,7 @@ The instance resource. ### Optional - `external_link` (String) The external console URL managing this instance (e.g. AWS RDS console, your in-house DB instance console) -- `maximum_connections` (Number) The maximum number of connections. The default value is 10. +- `maximum_connections` (Number) The maximum number of connections. - `sync_interval` (Number) How often the instance is synced in seconds. Default 0, means never sync. ### Read-Only diff --git a/provider/resource_instance.go b/provider/resource_instance.go index cf24f32..31f8f72 100644 --- a/provider/resource_instance.go +++ b/provider/resource_instance.go @@ -99,8 +99,8 @@ func resourceInstance() *schema.Resource { "maximum_connections": { Type: schema.TypeInt, Optional: true, - Default: 10, - Description: "The maximum number of connections. The default value is 10.", + Default: 0, + Description: "The maximum number of connections.", }, "data_sources": { Type: schema.TypeList, From 559605776a7f2a81690fe75b93f9e6a16e0e13ab Mon Sep 17 00:00:00 2001 From: ecmadao Date: Thu, 12 Dec 2024 14:09:31 +0800 Subject: [PATCH 7/9] chore: update examples --- examples/environments/main.tf | 2 +- examples/instances/main.tf | 2 +- examples/projects/main.tf | 2 +- examples/setup/approval_flow.tf | 0 examples/setup/data_masking.tf | 53 +++++++ examples/setup/environment.tf | 15 ++ examples/setup/gitops.tf | 25 +++ examples/setup/instance.tf | 54 +++++++ examples/setup/main.tf | 272 -------------------------------- examples/setup/project.tf | 33 ++++ examples/setup/users.tf | 38 +++++ 11 files changed, 221 insertions(+), 275 deletions(-) create mode 100644 examples/setup/approval_flow.tf create mode 100644 examples/setup/data_masking.tf create mode 100644 examples/setup/environment.tf create mode 100644 examples/setup/gitops.tf create mode 100644 examples/setup/instance.tf create mode 100644 examples/setup/project.tf create mode 100644 examples/setup/users.tf diff --git a/examples/environments/main.tf b/examples/environments/main.tf index e966b2f..114ef21 100644 --- a/examples/environments/main.tf +++ b/examples/environments/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { bytebase = { - version = "1.0.3" + version = "1.0.5" # For local development, please use "terraform.local/bytebase/bytebase" instead source = "registry.terraform.io/bytebase/bytebase" } diff --git a/examples/instances/main.tf b/examples/instances/main.tf index 4700e92..0a2b238 100644 --- a/examples/instances/main.tf +++ b/examples/instances/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { bytebase = { - version = "1.0.3" + version = "1.0.5" # For local development, please use "terraform.local/bytebase/bytebase" instead source = "registry.terraform.io/bytebase/bytebase" } diff --git a/examples/projects/main.tf b/examples/projects/main.tf index 7c30e4e..615156a 100644 --- a/examples/projects/main.tf +++ b/examples/projects/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { bytebase = { - version = "1.0.3" + version = "1.0.5" # For local development, please use "terraform.local/bytebase/bytebase" instead source = "registry.terraform.io/bytebase/bytebase" } diff --git a/examples/setup/approval_flow.tf b/examples/setup/approval_flow.tf new file mode 100644 index 0000000..e69de29 diff --git a/examples/setup/data_masking.tf b/examples/setup/data_masking.tf new file mode 100644 index 0000000..0a52d1a --- /dev/null +++ b/examples/setup/data_masking.tf @@ -0,0 +1,53 @@ +resource "bytebase_policy" "masking_policy" { + depends_on = [ + bytebase_instance.test + ] + + parent = "instances/test-sample-instance/databases/employee" + type = "MASKING" + enforce = true + inherit_from_parent = false + + masking_policy { + mask_data { + table = "salary" + column = "amount" + masking_level = "FULL" + } + mask_data { + table = "salary" + column = "emp_no" + masking_level = "NONE" + } + } +} + +resource "bytebase_policy" "masking_exception_policy" { + depends_on = [ + bytebase_project.sample_project + ] + + parent = bytebase_project.sample_project.name + type = "MASKING_EXCEPTION" + enforce = true + inherit_from_parent = false + + masking_exception_policy { + exceptions { + database = "instances/test-sample-instance/databases/employee" + table = "salary" + column = "amount" + masking_level = "NONE" + member = "user:ed@bytebase.com" + action = "EXPORT" + } + exceptions { + database = "instances/test-sample-instance/databases/employee" + table = "salary" + column = "amount" + masking_level = "NONE" + member = "user:ed@bytebase.com" + action = "QUERY" + } + } +} diff --git a/examples/setup/environment.tf b/examples/setup/environment.tf new file mode 100644 index 0000000..92b8d07 --- /dev/null +++ b/examples/setup/environment.tf @@ -0,0 +1,15 @@ +# Create a new environment named "Test" +resource "bytebase_environment" "test" { + resource_id = local.environment_id_test + title = "Test" + order = 0 + environment_tier_policy = "UNPROTECTED" +} + +# Create another environment named "Prod" +resource "bytebase_environment" "prod" { + resource_id = local.environment_id_prod + title = "Prod" + order = 1 + environment_tier_policy = "PROTECTED" +} diff --git a/examples/setup/gitops.tf b/examples/setup/gitops.tf new file mode 100644 index 0000000..a30fa6a --- /dev/null +++ b/examples/setup/gitops.tf @@ -0,0 +1,25 @@ +# Create GitHub GitOps provider. +resource "bytebase_vcs_provider" "github" { + resource_id = "vcs-github" + title = "GitHub GitOps" + type = "GITHUB" + access_token = "" +} + +# Connect to the GitHub repository. +resource "bytebase_vcs_connector" "github" { + depends_on = [ + bytebase_project.sample_project, + bytebase_vcs_provider.github + ] + + resource_id = "connector-github" + title = "GitHub Connector" + project = bytebase_project.sample_project.name + vcs_provider = bytebase_vcs_provider.github.name + repository_id = "ed-bytebase/gitops" + repository_path = "ed-bytebase/gitops" + repository_directory = "/bytebase" + repository_branch = "main" + repository_url = "https://github.com/ed-bytebase/gitops" +} diff --git a/examples/setup/instance.tf b/examples/setup/instance.tf new file mode 100644 index 0000000..1a0b91b --- /dev/null +++ b/examples/setup/instance.tf @@ -0,0 +1,54 @@ + +# Create a new instance named "test instance" +# You can replace the parameters with your real instance +resource "bytebase_instance" "test" { + depends_on = [ + bytebase_environment.test + ] + resource_id = local.instance_id_test + environment = bytebase_environment.test.name + title = "test instance" + engine = "MYSQL" + + # You need to specific the data source + data_sources { + id = "admin data source" + type = "ADMIN" + username = "" + password = "" + host = "127.0.0.1" + port = "3366" + } + + # And you can add another data_sources with RO type + data_sources { + id = "read-only data source" + type = "READ_ONLY" + username = "" + password = "" + host = "127.0.0.1" + port = "3366" + } +} + +# Create a new instance named "prod instance" +resource "bytebase_instance" "prod" { + depends_on = [ + bytebase_environment.prod + ] + + resource_id = local.instance_id_prod + environment = bytebase_environment.prod.name + title = "prod instance" + engine = "POSTGRES" + + # You need to specific the data source + data_sources { + id = "admin data source" + type = "ADMIN" + username = "" + password = "" + host = "127.0.0.1" + port = "54321" + } +} diff --git a/examples/setup/main.tf b/examples/setup/main.tf index b7cd87c..36a58d6 100644 --- a/examples/setup/main.tf +++ b/examples/setup/main.tf @@ -25,275 +25,3 @@ locals { instance_id_prod = "prod-sample-instance" project_id = "project-sample" } - -# Create a new environment named "Test" -resource "bytebase_environment" "test" { - resource_id = local.environment_id_test - title = "Test" - order = 0 - environment_tier_policy = "UNPROTECTED" -} - -# Create another environment named "Prod" -resource "bytebase_environment" "prod" { - resource_id = local.environment_id_prod - title = "Prod" - order = 1 - environment_tier_policy = "PROTECTED" -} - -# Create a new instance named "test instance" -# You can replace the parameters with your real instance -resource "bytebase_instance" "test" { - depends_on = [ - bytebase_environment.test - ] - resource_id = local.instance_id_test - environment = bytebase_environment.test.name - title = "test instance" - engine = "MYSQL" - - # You need to specific the data source - data_sources { - id = "admin data source" - type = "ADMIN" - username = "" - password = "" - host = "127.0.0.1" - port = "3366" - } - - # And you can add another data_sources with RO type - data_sources { - id = "read-only data source" - type = "READ_ONLY" - username = "" - password = "" - host = "127.0.0.1" - port = "3366" - } -} - -# Create a new instance named "prod instance" -resource "bytebase_instance" "prod" { - depends_on = [ - bytebase_environment.prod - ] - - resource_id = local.instance_id_prod - environment = bytebase_environment.prod.name - title = "prod instance" - engine = "POSTGRES" - - # You need to specific the data source - data_sources { - id = "admin data source" - type = "ADMIN" - username = "" - password = "" - host = "127.0.0.1" - port = "54321" - } -} - -# Create a new user. -resource "bytebase_user" "workspace_dba" { - title = "DBA" - email = "dba@bytebase.com" - - # Grant workspace level roles. - roles = ["roles/workspaceDBA"] -} - -# Create a new user. -resource "bytebase_user" "project_developer" { - title = "Developer" - email = "developer@bytebase.com" - - # Grant workspace level roles, will grant projectViewer for this user in all - roles = ["roles/projectViewer"] -} - -resource "bytebase_group" "developers" { - depends_on = [ - bytebase_user.workspace_dba, - bytebase_user.project_developer - ] - - email = "developers@bytebase.com" - title = "Bytebase Developers" - - members { - member = format("users/%s", bytebase_user.workspace_dba.email) - role = "OWNER" - } - - members { - member = format("users/%s", bytebase_user.project_developer.email) - role = "MEMBER" - } -} - -# Create a new project -resource "bytebase_project" "sample_project" { - depends_on = [ - bytebase_user.workspace_dba, - bytebase_user.project_developer, - bytebase_group.developers - ] - - resource_id = local.project_id - title = "Sample project" - key = "SAMM" - - members { - member = format("user:%s", bytebase_user.workspace_dba.email) - role = "roles/projectOwner" - } - - members { - member = format("group:%s", bytebase_group.developers.email) - role = "roles/projectDeveloper" - } - - members { - member = format("user:%s", bytebase_user.project_developer.email) - role = "roles/projectExporter" - condition { - database = "instances/test-sample-instance/databases/employee" - tables = ["dept_emp", "dept_manager"] - row_limit = 10000 - expire_timestamp = "2027-03-09T16:17:49.637Z" - } - } -} - -resource "bytebase_setting" "external_approval" { - name = "bb.workspace.approval.external" - - external_approval_nodes { - nodes { - id = "9e150339-f014-4835-83d7-123aeb1895ba" - title = "Example node" - endpoint = "https://example.com" - } - - nodes { - id = "49a976be-50de-4541-b2d3-f2e32f8e41ef" - title = "Example node 2" - endpoint = "https://example.com" - } - } -} - -resource "bytebase_setting" "approval_flow" { - name = "bb.workspace.approval" - approval_flow { - rules { - flow { - title = "DBA -> OWNER" - description = "Need DBA and workspace owner approval" - creator = "users/support@bytebase.com" - - # Approval flow following the step order. - steps { - type = "GROUP" - node = "WORKSPACE_DBA" - } - - steps { - type = "GROUP" - node = "WORKSPACE_OWNER" - } - } - - # Match any condition will trigger this approval flow. - conditions { - source = "DML" - level = "MODERATE" - } - conditions { - source = "DDL" - level = "HIGH" - } - } - } -} - -resource "bytebase_policy" "masking_policy" { - depends_on = [ - bytebase_instance.test - ] - - parent = "instances/test-sample-instance/databases/employee" - type = "MASKING" - enforce = true - inherit_from_parent = false - - masking_policy { - mask_data { - table = "salary" - column = "amount" - masking_level = "FULL" - } - mask_data { - table = "salary" - column = "emp_no" - masking_level = "NONE" - } - } -} - -resource "bytebase_policy" "masking_exception_policy" { - depends_on = [ - bytebase_project.sample_project - ] - - parent = bytebase_project.sample_project.name - type = "MASKING_EXCEPTION" - enforce = true - inherit_from_parent = false - - masking_exception_policy { - exceptions { - database = "instances/test-sample-instance/databases/employee" - table = "salary" - column = "amount" - masking_level = "NONE" - member = "user:ed@bytebase.com" - action = "EXPORT" - } - exceptions { - database = "instances/test-sample-instance/databases/employee" - table = "salary" - column = "amount" - masking_level = "NONE" - member = "user:ed@bytebase.com" - action = "QUERY" - } - } -} - -resource "bytebase_vcs_provider" "github" { - resource_id = "vcs-github" - title = "GitHub GitOps" - type = "GITHUB" - access_token = "" -} - -resource "bytebase_vcs_connector" "github" { - depends_on = [ - bytebase_project.sample_project, - bytebase_vcs_provider.github - ] - - resource_id = "connector-github" - title = "GitHub Connector" - project = bytebase_project.sample_project.name - vcs_provider = bytebase_vcs_provider.github.name - repository_id = "ed-bytebase/gitops" - repository_path = "ed-bytebase/gitops" - repository_directory = "/bytebase" - repository_branch = "main" - repository_url = "https://github.com/ed-bytebase/gitops" -} diff --git a/examples/setup/project.tf b/examples/setup/project.tf new file mode 100644 index 0000000..287fc99 --- /dev/null +++ b/examples/setup/project.tf @@ -0,0 +1,33 @@ +# Create or update sample project, and grant roles for users and groups. +resource "bytebase_project" "sample_project" { + depends_on = [ + bytebase_user.workspace_dba, + bytebase_user.project_developer, + bytebase_group.developers + ] + + resource_id = local.project_id + title = "Sample project" + key = "SAMM" + + members { + member = format("user:%s", bytebase_user.workspace_dba.email) + role = "roles/projectOwner" + } + + members { + member = format("group:%s", bytebase_group.developers.email) + role = "roles/projectDeveloper" + } + + members { + member = format("user:%s", bytebase_user.project_developer.email) + role = "roles/projectExporter" + condition { + database = "instances/test-sample-instance/databases/employee" + tables = ["dept_emp", "dept_manager"] + row_limit = 10000 + expire_timestamp = "2027-03-09T16:17:49.637Z" + } + } +} diff --git a/examples/setup/users.tf b/examples/setup/users.tf new file mode 100644 index 0000000..99c6136 --- /dev/null +++ b/examples/setup/users.tf @@ -0,0 +1,38 @@ +# Create or update the user. +resource "bytebase_user" "workspace_dba" { + title = "DBA" + email = "dba@bytebase.com" + + # Grant workspace level roles. + roles = ["roles/workspaceDBA"] +} + +# Create or update the user. +resource "bytebase_user" "project_developer" { + title = "Developer" + email = "developer@bytebase.com" + + # Grant workspace level roles, will grant projectViewer for this user in all + roles = ["roles/projectViewer"] +} + +# Create or update the group. +resource "bytebase_group" "developers" { + depends_on = [ + bytebase_user.workspace_dba, + bytebase_user.project_developer + ] + + email = "developers@bytebase.com" + title = "Bytebase Developers" + + members { + member = format("users/%s", bytebase_user.workspace_dba.email) + role = "OWNER" + } + + members { + member = format("users/%s", bytebase_user.project_developer.email) + role = "MEMBER" + } +} From 41949b211be83cbdf1ba7fbe611fc60d98bc9a65 Mon Sep 17 00:00:00 2001 From: ecmadao Date: Tue, 7 Jan 2025 14:59:19 +0800 Subject: [PATCH 8/9] chore: support database catalog --- VERSION | 2 +- api/client.go | 4 + client/database.go | 30 +++ docs/data-sources/database_catalog.md | 54 +++++ docs/data-sources/policy.md | 23 --- docs/data-sources/policy_list.md | 23 --- docs/data-sources/setting.md | 4 +- docs/resources/database_catalog.md | 63 ++++++ docs/resources/environment.md | 2 +- docs/resources/group.md | 4 +- docs/resources/policy.md | 23 --- docs/resources/setting.md | 4 +- examples/database/main.tf | 29 +++ examples/environments/main.tf | 2 +- examples/groups/main.tf | 2 +- examples/instances/main.tf | 2 +- examples/policies/main.tf | 11 +- examples/projects/main.tf | 2 +- examples/settings/main.tf | 2 +- examples/setup/approval_flow.tf | 52 +++++ examples/setup/data_masking.tf | 58 +++--- examples/setup/main.tf | 2 +- examples/users/main.tf | 2 +- examples/vcs/main.tf | 2 +- go.mod | 24 +-- go.sum | 62 +++--- provider/data_source_database_catalog.go | 140 +++++++++++++ provider/data_source_policy.go | 100 +--------- provider/data_source_policy_list.go | 6 +- provider/data_source_policy_list_test.go | 10 +- provider/data_source_policy_test.go | 35 ++-- provider/data_source_setting.go | 18 +- provider/internal/mock_client.go | 31 +-- provider/internal/utils.go | 2 + provider/provider.go | 20 +- provider/resource_database_catalog.go | 241 +++++++++++++++++++++++ provider/resource_environment.go | 2 +- provider/resource_group.go | 2 +- provider/resource_policy.go | 51 ----- provider/resource_policy_test.go | 37 +--- 40 files changed, 782 insertions(+), 401 deletions(-) create mode 100644 docs/data-sources/database_catalog.md create mode 100644 docs/resources/database_catalog.md create mode 100644 examples/database/main.tf create mode 100644 provider/data_source_database_catalog.go create mode 100644 provider/resource_database_catalog.go diff --git a/VERSION b/VERSION index 1464c52..ece61c6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.5 \ No newline at end of file +1.0.6 \ No newline at end of file diff --git a/api/client.go b/api/client.go index bb8a57c..56f0864 100644 --- a/api/client.go +++ b/api/client.go @@ -59,6 +59,10 @@ type Client interface { ListDatabase(ctx context.Context, instanceID, filter string) (*v1pb.ListDatabasesResponse, error) // UpdateDatabase patches the database. UpdateDatabase(ctx context.Context, patch *v1pb.Database, updateMasks []string) (*v1pb.Database, error) + // GetDatabaseCatalog gets the database catalog by the database full name. + GetDatabaseCatalog(ctx context.Context, databaseName string) (*v1pb.DatabaseCatalog, error) + // UpdateDatabaseCatalog patches the database catalog. + UpdateDatabaseCatalog(ctx context.Context, patch *v1pb.DatabaseCatalog, updateMasks []string) (*v1pb.DatabaseCatalog, error) // Project // GetProject gets the project by project full name. diff --git a/client/database.go b/client/database.go index 5c2277a..00b29cf 100644 --- a/client/database.go +++ b/client/database.go @@ -63,3 +63,33 @@ func (c *client) UpdateDatabase(ctx context.Context, patch *v1pb.Database, updat return &res, nil } + +// GetDatabaseCatalog gets the database catalog by the database full name. +func (c *client) GetDatabaseCatalog(ctx context.Context, databaseName string) (*v1pb.DatabaseCatalog, error) { + body, err := c.getResource(ctx, fmt.Sprintf("%s/catalog", databaseName)) + if err != nil { + return nil, err + } + + var res v1pb.DatabaseCatalog + if err := ProtojsonUnmarshaler.Unmarshal(body, &res); err != nil { + return nil, err + } + + return &res, nil +} + +// UpdateDatabaseCatalog patches the database catalog. +func (c *client) UpdateDatabaseCatalog(ctx context.Context, patch *v1pb.DatabaseCatalog, updateMasks []string) (*v1pb.DatabaseCatalog, error) { + body, err := c.updateResource(ctx, patch.Name, patch, updateMasks, false /* allow missing = false*/) + if err != nil { + return nil, err + } + + var res v1pb.DatabaseCatalog + if err := ProtojsonUnmarshaler.Unmarshal(body, &res); err != nil { + return nil, err + } + + return &res, nil +} diff --git a/docs/data-sources/database_catalog.md b/docs/data-sources/database_catalog.md new file mode 100644 index 0000000..fa4d185 --- /dev/null +++ b/docs/data-sources/database_catalog.md @@ -0,0 +1,54 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "bytebase_database_catalog Data Source - terraform-provider-bytebase" +subcategory: "" +description: |- + The database catalog data source. +--- + +# bytebase_database_catalog (Data Source) + +The database catalog data source. + + + + +## Schema + +### Required + +- `database` (String) The database full name in instances/{instance}/databases/{database} format + +### Read-Only + +- `id` (String) The ID of this resource. +- `schemas` (List of Object) (see [below for nested schema](#nestedatt--schemas)) + + +### Nested Schema for `schemas` + +Read-Only: + +- `name` (String) +- `tables` (List of Object) (see [below for nested schema](#nestedobjatt--schemas--tables)) + + +### Nested Schema for `schemas.tables` + +Read-Only: + +- `classification` (String) +- `columns` (List of Object) (see [below for nested schema](#nestedobjatt--schemas--tables--columns)) +- `name` (String) + + +### Nested Schema for `schemas.tables.columns` + +Read-Only: + +- `classification` (String) +- `labels` (Map of String) +- `name` (String) +- `semantic_type` (String) + + diff --git a/docs/data-sources/policy.md b/docs/data-sources/policy.md index 029fa32..f99bc66 100644 --- a/docs/data-sources/policy.md +++ b/docs/data-sources/policy.md @@ -22,7 +22,6 @@ The policy data source. ### Optional - `masking_exception_policy` (Block List, Max: 1) (see [below for nested schema](#nestedblock--masking_exception_policy)) -- `masking_policy` (Block List, Max: 1) (see [below for nested schema](#nestedblock--masking_policy)) - `parent` (String) The policy parent name for the policy, support projects/{resource id}, environments/{resource id}, instances/{resource id}, or instances/{resource id}/databases/{database name} ### Read-Only @@ -48,30 +47,8 @@ Optional: - `column` (String) - `database` (String) The database full name in instances/{instance resource id}/databases/{database name} format - `expire_timestamp` (String) The expiration timestamp in YYYY-MM-DDThh:mm:ss.000Z format -- `masking_level` (String) - `member` (String) The member in user:{email} or group:{email} format. - `schema` (String) - `table` (String) - - -### Nested Schema for `masking_policy` - -Optional: - -- `mask_data` (Block List) (see [below for nested schema](#nestedblock--masking_policy--mask_data)) - - -### Nested Schema for `masking_policy.mask_data` - -Optional: - -- `column` (String) -- `full_masking_algorithm_id` (String) -- `masking_level` (String) -- `partial_masking_algorithm_id` (String) -- `schema` (String) -- `table` (String) - - diff --git a/docs/data-sources/policy_list.md b/docs/data-sources/policy_list.md index 90390fd..3b3db7c 100644 --- a/docs/data-sources/policy_list.md +++ b/docs/data-sources/policy_list.md @@ -32,7 +32,6 @@ Read-Only: - `enforce` (Boolean) - `inherit_from_parent` (Boolean) - `masking_exception_policy` (List of Object) (see [below for nested schema](#nestedobjatt--policies--masking_exception_policy)) -- `masking_policy` (List of Object) (see [below for nested schema](#nestedobjatt--policies--masking_policy)) - `name` (String) - `type` (String) @@ -52,30 +51,8 @@ Read-Only: - `column` (String) - `database` (String) - `expire_timestamp` (String) -- `masking_level` (String) - `member` (String) - `schema` (String) - `table` (String) - - -### Nested Schema for `policies.masking_policy` - -Read-Only: - -- `mask_data` (List of Object) (see [below for nested schema](#nestedobjatt--policies--masking_policy--mask_data)) - - -### Nested Schema for `policies.masking_policy.mask_data` - -Read-Only: - -- `column` (String) -- `full_masking_algorithm_id` (String) -- `masking_level` (String) -- `partial_masking_algorithm_id` (String) -- `schema` (String) -- `table` (String) - - diff --git a/docs/data-sources/setting.md b/docs/data-sources/setting.md index 0c9bf2e..1ce3d3d 100644 --- a/docs/data-sources/setting.md +++ b/docs/data-sources/setting.md @@ -21,8 +21,8 @@ The setting data source. ### Read-Only -- `approval_flow` (Block List) (see [below for nested schema](#nestedblock--approval_flow)) -- `external_approval_nodes` (Block List) (see [below for nested schema](#nestedblock--external_approval_nodes)) +- `approval_flow` (Block List) Configure risk level and approval flow for different tasks. Require ENTERPRISE subscription. (see [below for nested schema](#nestedblock--approval_flow)) +- `external_approval_nodes` (Block List) Configure external nodes in the approval flow. Require ENTERPRISE subscription. (see [below for nested schema](#nestedblock--external_approval_nodes)) - `id` (String) The ID of this resource. diff --git a/docs/resources/database_catalog.md b/docs/resources/database_catalog.md new file mode 100644 index 0000000..16f8ead --- /dev/null +++ b/docs/resources/database_catalog.md @@ -0,0 +1,63 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "bytebase_database_catalog Resource - terraform-provider-bytebase" +subcategory: "" +description: |- + The database catalog resource. +--- + +# bytebase_database_catalog (Resource) + +The database catalog resource. + + + + +## Schema + +### Required + +- `database` (String) The database full name in instances/{instance}/databases/{database} format +- `schemas` (Block List, Min: 1) (see [below for nested schema](#nestedblock--schemas)) + +### Read-Only + +- `id` (String) The ID of this resource. + + +### Nested Schema for `schemas` + +Required: + +- `tables` (Block List, Min: 1) (see [below for nested schema](#nestedblock--schemas--tables)) + +Optional: + +- `name` (String) + + +### Nested Schema for `schemas.tables` + +Required: + +- `columns` (Block List, Min: 1) (see [below for nested schema](#nestedblock--schemas--tables--columns)) +- `name` (String) + +Optional: + +- `classification` (String) The classification id + + +### Nested Schema for `schemas.tables.columns` + +Required: + +- `name` (String) + +Optional: + +- `classification` (String) The classification id +- `labels` (Map of String) +- `semantic_type` (String) The semantic type id + + diff --git a/docs/resources/environment.md b/docs/resources/environment.md index d9f22d1..d78ac86 100644 --- a/docs/resources/environment.md +++ b/docs/resources/environment.md @@ -17,7 +17,7 @@ The environment resource. ### Required -- `environment_tier_policy` (String) If marked as PROTECTED, developers cannot execute any query on this environment's databases using SQL Editor by default. +- `environment_tier_policy` (String) If marked as PROTECTED, developers cannot execute any query on this environment's databases using SQL Editor by default. Require ENTERPRISE subscription. - `order` (Number) The environment sorting order. - `resource_id` (String) The environment unique resource id. - `title` (String) The environment title. diff --git a/docs/resources/group.md b/docs/resources/group.md index 79f4eca..fa628a4 100644 --- a/docs/resources/group.md +++ b/docs/resources/group.md @@ -3,12 +3,12 @@ page_title: "bytebase_group Resource - terraform-provider-bytebase" subcategory: "" description: |- - The group resource. + The group resource. Workspace domain is required for creating groups. --- # bytebase_group (Resource) -The group resource. +The group resource. Workspace domain is required for creating groups. diff --git a/docs/resources/policy.md b/docs/resources/policy.md index b26f102..dc743a2 100644 --- a/docs/resources/policy.md +++ b/docs/resources/policy.md @@ -25,7 +25,6 @@ The policy resource. - `enforce` (Boolean) Decide if the policy is enforced. - `inherit_from_parent` (Boolean) Decide if the policy should inherit from the parent. - `masking_exception_policy` (Block List, Max: 1) (see [below for nested schema](#nestedblock--masking_exception_policy)) -- `masking_policy` (Block List, Max: 1) (see [below for nested schema](#nestedblock--masking_policy)) ### Read-Only @@ -48,30 +47,8 @@ Optional: - `column` (String) - `database` (String) The database full name in instances/{instance resource id}/databases/{database name} format - `expire_timestamp` (String) The expiration timestamp in YYYY-MM-DDThh:mm:ss.000Z format -- `masking_level` (String) - `member` (String) The member in user:{email} or group:{email} format. - `schema` (String) - `table` (String) - - -### Nested Schema for `masking_policy` - -Optional: - -- `mask_data` (Block List) (see [below for nested schema](#nestedblock--masking_policy--mask_data)) - - -### Nested Schema for `masking_policy.mask_data` - -Optional: - -- `column` (String) -- `full_masking_algorithm_id` (String) -- `masking_level` (String) -- `partial_masking_algorithm_id` (String) -- `schema` (String) -- `table` (String) - - diff --git a/docs/resources/setting.md b/docs/resources/setting.md index 93938a6..4912e6f 100644 --- a/docs/resources/setting.md +++ b/docs/resources/setting.md @@ -21,8 +21,8 @@ The setting resource. ### Optional -- `approval_flow` (Block List) (see [below for nested schema](#nestedblock--approval_flow)) -- `external_approval_nodes` (Block List) (see [below for nested schema](#nestedblock--external_approval_nodes)) +- `approval_flow` (Block List) Configure risk level and approval flow for different tasks. Require ENTERPRISE subscription. (see [below for nested schema](#nestedblock--approval_flow)) +- `external_approval_nodes` (Block List) Configure external nodes in the approval flow. Require ENTERPRISE subscription. (see [below for nested schema](#nestedblock--external_approval_nodes)) ### Read-Only diff --git a/examples/database/main.tf b/examples/database/main.tf new file mode 100644 index 0000000..e139030 --- /dev/null +++ b/examples/database/main.tf @@ -0,0 +1,29 @@ +# Examples for query the environments +terraform { + required_providers { + bytebase = { + version = "1.0.6" + # For local development, please use "terraform.local/bytebase/bytebase" instead + source = "registry.terraform.io/bytebase/bytebase" + } + } +} + +provider "bytebase" { + # You need to replace the account and key with your Bytebase service account. + service_account = "terraform@service.bytebase.com" + service_key = "bbs_BxVIp7uQsARl8nR92ZZV" + # The Bytebase service URL. You can use the external URL in production. + # Check the docs about external URL: https://www.bytebase.com/docs/get-started/install/external-url + url = "https://bytebase.example.com" +} + + + +data "bytebase_database_catalog" "employee" { + database = "instances/test-sample-instance/databases/employee" +} + +output "employee_catalog" { + value = data.bytebase_database_catalog.employee +} diff --git a/examples/environments/main.tf b/examples/environments/main.tf index 114ef21..8929126 100644 --- a/examples/environments/main.tf +++ b/examples/environments/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { bytebase = { - version = "1.0.5" + version = "1.0.6" # For local development, please use "terraform.local/bytebase/bytebase" instead source = "registry.terraform.io/bytebase/bytebase" } diff --git a/examples/groups/main.tf b/examples/groups/main.tf index b7fa47c..7d0bc8f 100644 --- a/examples/groups/main.tf +++ b/examples/groups/main.tf @@ -1,7 +1,7 @@ terraform { required_providers { bytebase = { - version = "1.0.5" + version = "1.0.6" # For local development, please use "terraform.local/bytebase/bytebase" instead source = "registry.terraform.io/bytebase/bytebase" } diff --git a/examples/instances/main.tf b/examples/instances/main.tf index 0a2b238..cac84e7 100644 --- a/examples/instances/main.tf +++ b/examples/instances/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { bytebase = { - version = "1.0.5" + version = "1.0.6" # For local development, please use "terraform.local/bytebase/bytebase" instead source = "registry.terraform.io/bytebase/bytebase" } diff --git a/examples/policies/main.tf b/examples/policies/main.tf index 416cb5b..3b9b7a4 100644 --- a/examples/policies/main.tf +++ b/examples/policies/main.tf @@ -1,7 +1,7 @@ terraform { required_providers { bytebase = { - version = "1.0.5" + version = "1.0.6" # For local development, please use "terraform.local/bytebase/bytebase" instead source = "registry.terraform.io/bytebase/bytebase" } @@ -17,20 +17,11 @@ provider "bytebase" { url = "https://bytebase.example.com" } -data "bytebase_policy" "masking_policy" { - parent = "instances/test-sample-instance/databases/employee" - type = "MASKING" -} - data "bytebase_policy" "masking_exception_policy" { parent = "projects/project-sample" type = "MASKING_EXCEPTION" } -output "masking_policy" { - value = data.bytebase_policy.masking_policy -} - output "masking_exception_policy" { value = data.bytebase_policy.masking_exception_policy } diff --git a/examples/projects/main.tf b/examples/projects/main.tf index 615156a..5421c88 100644 --- a/examples/projects/main.tf +++ b/examples/projects/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { bytebase = { - version = "1.0.5" + version = "1.0.6" # For local development, please use "terraform.local/bytebase/bytebase" instead source = "registry.terraform.io/bytebase/bytebase" } diff --git a/examples/settings/main.tf b/examples/settings/main.tf index 095fedc..8dc2b66 100644 --- a/examples/settings/main.tf +++ b/examples/settings/main.tf @@ -1,7 +1,7 @@ terraform { required_providers { bytebase = { - version = "1.0.5" + version = "1.0.6" # For local development, please use "terraform.local/bytebase/bytebase" instead source = "registry.terraform.io/bytebase/bytebase" } diff --git a/examples/setup/approval_flow.tf b/examples/setup/approval_flow.tf index e69de29..de0bce1 100644 --- a/examples/setup/approval_flow.tf +++ b/examples/setup/approval_flow.tf @@ -0,0 +1,52 @@ + +resource "bytebase_setting" "external_approval" { + name = "bb.workspace.approval.external" + + external_approval_nodes { + nodes { + id = "9e150339-f014-4835-83d7-123aeb1895ba" + title = "Example node" + endpoint = "https://example.com" + } + + nodes { + id = "49a976be-50de-4541-b2d3-f2e32f8e41ef" + title = "Example node 2" + endpoint = "https://example.com" + } + } +} + +resource "bytebase_setting" "approval_flow" { + name = "bb.workspace.approval" + approval_flow { + rules { + flow { + title = "DBA -> OWNER" + description = "Need DBA and workspace owner approval" + creator = "users/support@bytebase.com" + + # Approval flow following the step order. + steps { + type = "GROUP" + node = "WORKSPACE_DBA" + } + + steps { + type = "GROUP" + node = "WORKSPACE_OWNER" + } + } + + # Match any condition will trigger this approval flow. + conditions { + source = "DML" + level = "MODERATE" + } + conditions { + source = "DDL" + level = "HIGH" + } + } + } +} diff --git a/examples/setup/data_masking.tf b/examples/setup/data_masking.tf index 0a52d1a..74be919 100644 --- a/examples/setup/data_masking.tf +++ b/examples/setup/data_masking.tf @@ -1,30 +1,34 @@ -resource "bytebase_policy" "masking_policy" { +resource "bytebase_database_catalog" "employee_catalog" { depends_on = [ bytebase_instance.test ] - parent = "instances/test-sample-instance/databases/employee" - type = "MASKING" - enforce = true - inherit_from_parent = false + database = "instances/test-sample-instance/databases/employee" - masking_policy { - mask_data { - table = "salary" - column = "amount" - masking_level = "FULL" - } - mask_data { - table = "salary" - column = "emp_no" - masking_level = "NONE" + schemas { + tables { + name = "salary" + columns { + name = "amount" + semantic_type = "default" + classification = "1-1-1" + } + columns { + name = "emp_no" + semantic_type = "default-partial" + labels = { + tenant = "example" + region = "asia" + } + } } } } resource "bytebase_policy" "masking_exception_policy" { depends_on = [ - bytebase_project.sample_project + bytebase_project.sample_project, + bytebase_instance.test ] parent = bytebase_project.sample_project.name @@ -34,20 +38,18 @@ resource "bytebase_policy" "masking_exception_policy" { masking_exception_policy { exceptions { - database = "instances/test-sample-instance/databases/employee" - table = "salary" - column = "amount" - masking_level = "NONE" - member = "user:ed@bytebase.com" - action = "EXPORT" + database = "instances/test-sample-instance/databases/employee" + table = "salary" + column = "amount" + member = "user:ed@bytebase.com" + action = "EXPORT" } exceptions { - database = "instances/test-sample-instance/databases/employee" - table = "salary" - column = "amount" - masking_level = "NONE" - member = "user:ed@bytebase.com" - action = "QUERY" + database = "instances/test-sample-instance/databases/employee" + table = "salary" + column = "amount" + member = "user:ed@bytebase.com" + action = "QUERY" } } } diff --git a/examples/setup/main.tf b/examples/setup/main.tf index 36a58d6..580fef4 100644 --- a/examples/setup/main.tf +++ b/examples/setup/main.tf @@ -1,7 +1,7 @@ terraform { required_providers { bytebase = { - version = "1.0.5" + version = "1.0.6" # For local development, please use "terraform.local/bytebase/bytebase" instead source = "registry.terraform.io/bytebase/bytebase" } diff --git a/examples/users/main.tf b/examples/users/main.tf index 2baadf6..a571ee7 100644 --- a/examples/users/main.tf +++ b/examples/users/main.tf @@ -1,7 +1,7 @@ terraform { required_providers { bytebase = { - version = "1.0.5" + version = "1.0.6" # For local development, please use "terraform.local/bytebase/bytebase" instead source = "registry.terraform.io/bytebase/bytebase" } diff --git a/examples/vcs/main.tf b/examples/vcs/main.tf index 4e5b9fc..5d9c9f7 100644 --- a/examples/vcs/main.tf +++ b/examples/vcs/main.tf @@ -1,7 +1,7 @@ terraform { required_providers { bytebase = { - version = "1.0.5" + version = "1.0.6" # For local development, please use "terraform.local/bytebase/bytebase" instead source = "registry.terraform.io/bytebase/bytebase" } diff --git a/go.mod b/go.mod index 449b615..b740a5f 100644 --- a/go.mod +++ b/go.mod @@ -1,17 +1,17 @@ module github.com/bytebase/terraform-provider-bytebase -go 1.23.2 +go 1.23.4 require ( - github.com/bytebase/bytebase v0.0.0-20241205093738-38cba35b1547 + github.com/bytebase/bytebase v0.0.0-20250106034445-3306b7f8a2cb github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320 github.com/hashicorp/terraform-plugin-docs v0.13.0 github.com/hashicorp/terraform-plugin-log v0.7.0 github.com/hashicorp/terraform-plugin-sdk/v2 v2.24.0 github.com/pkg/errors v0.9.1 - google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 - google.golang.org/genproto/googleapis/api v0.0.0-20241206012308-a4fef0638583 - google.golang.org/protobuf v1.35.2 + google.golang.org/genproto v0.0.0-20241230172942-26aa7a208def + google.golang.org/genproto/googleapis/api v0.0.0-20241230172942-26aa7a208def + google.golang.org/protobuf v1.36.1 ) require ( @@ -27,7 +27,7 @@ require ( github.com/golang/protobuf v1.5.4 // indirect github.com/google/go-cmp v0.6.0 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-checkpoint v0.5.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect @@ -64,11 +64,11 @@ require ( github.com/vmihailenco/msgpack/v4 v4.3.12 // indirect github.com/vmihailenco/tagparser v0.1.1 // indirect github.com/zclconf/go-cty v1.11.0 // indirect - golang.org/x/crypto v0.29.0 // indirect - golang.org/x/net v0.31.0 // indirect - golang.org/x/sys v0.27.0 // indirect - golang.org/x/text v0.20.0 // indirect + golang.org/x/crypto v0.31.0 // indirect + golang.org/x/net v0.33.0 // indirect + golang.org/x/sys v0.28.0 // indirect + golang.org/x/text v0.21.0 // indirect google.golang.org/appengine v1.6.7 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20241118233622-e639e219e697 // indirect - google.golang.org/grpc v1.68.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20241230172942-26aa7a208def // indirect + google.golang.org/grpc v1.69.2 // indirect ) diff --git a/go.sum b/go.sum index 7e3736f..4077d7c 100644 --- a/go.sum +++ b/go.sum @@ -29,8 +29,8 @@ github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgI github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQkY= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= -github.com/bytebase/bytebase v0.0.0-20241205093738-38cba35b1547 h1:4iW1KxDfqJkTbebeuiVgzxxrN6X/Ask1ic9MDFmIfno= -github.com/bytebase/bytebase v0.0.0-20241205093738-38cba35b1547/go.mod h1:3eFZBpvlGqEmGwGK6x7sgdUth4I6uNCNTw57fRFuXr8= +github.com/bytebase/bytebase v0.0.0-20250106034445-3306b7f8a2cb h1:wzMO4Lr1bh5YH0+y67zg1xF+AgXaQuj3A1aotSXNS1M= +github.com/bytebase/bytebase v0.0.0-20250106034445-3306b7f8a2cb/go.mod h1:VW4jbd0t2jCPazRYDVBFrLCNa+Q8PHqPQV8hP2g0OiU= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -53,6 +53,10 @@ github.com/go-git/go-billy/v5 v5.3.1/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI github.com/go-git/go-git-fixtures/v4 v4.2.1/go.mod h1:K8zd3kDUAykwTdDCr+I0per6Y6vMiRR/nnVTBtavnB0= github.com/go-git/go-git/v5 v5.4.2 h1:BXyZu9t0VkbiHtqrsvdq39UDhGJTl1h55VW6CSC4aY4= github.com/go-git/go-git/v5 v5.4.2/go.mod h1:gQ1kArt6d+n+BGd+/B/I74HwRTLhth2+zti4ihgckDc= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -70,8 +74,8 @@ github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0 h1:ad0vkEBuk23VJzZR9nkLVG0YAoN9coASF1GusYX6AlU= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0/go.mod h1:igFoXX2ELCW06bol23DWPB5BEWfZISOzSP5K2sbLea0= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 h1:TmHmbvxPmaegwhDubVz0lICL0J5Ka2vwTzhoePEXsGE= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0/go.mod h1:qztMSjm835F2bXf+5HKAPIS5qsmQDqZna/PgVt4rWtI= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -213,8 +217,8 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= @@ -230,6 +234,16 @@ github.com/zclconf/go-cty v1.10.0/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uU github.com/zclconf/go-cty v1.11.0 h1:726SxLdi2SDnjY+BStqB9J1hNp4+2WlzyXLuimibIe0= github.com/zclconf/go-cty v1.11.0/go.mod h1:s9IfD1LK5ccNMSWCVFCE2rJfHiZgi7JijgeWIMfhLvA= github.com/zclconf/go-cty-debug v0.0.0-20191215020915-b22d67c1ba0b/go.mod h1:ZRKQfBXbGkpdV6QMzT3rU1kSTAnfu1dO8dPKjYprgj8= +go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY= +go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE= +go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE= +go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY= +go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk= +go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0= +go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc= +go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8= +go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys= +go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200414173820-0848c9571904/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= @@ -237,8 +251,8 @@ golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= -golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180811021610-c39426892332/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -249,8 +263,8 @@ golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210326060303-6b1517762897/go.mod h1:uSPa2vr4CLtc/ILN5odXGNXS6mhrKVzTaCXzk9m6W3k= -golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo= -golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -272,31 +286,31 @@ golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= -golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= -golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 h1:ToEetK57OidYuqD4Q5w+vfEnPvPpuTwedCNVohYJfNk= -google.golang.org/genproto v0.0.0-20241118233622-e639e219e697/go.mod h1:JJrvXBWRZaFMxBufik1a4RpFw4HhgVtBBWQeQgUj2cc= -google.golang.org/genproto/googleapis/api v0.0.0-20241206012308-a4fef0638583 h1:v+j+5gpj0FopU0KKLDGfDo9ZRRpKdi5UBrCP0f76kuY= -google.golang.org/genproto/googleapis/api v0.0.0-20241206012308-a4fef0638583/go.mod h1:jehYqy3+AhJU9ve55aNOaSml7wUXjF9x6z2LcCfpAhY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20241118233622-e639e219e697 h1:LWZqQOEjDyONlF1H6afSWpAL/znlREo2tHfLoe+8LMA= -google.golang.org/genproto/googleapis/rpc v0.0.0-20241118233622-e639e219e697/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU= -google.golang.org/grpc v1.68.0 h1:aHQeeJbo8zAkAa3pRzrVjZlbz6uSfeOXlJNQM0RAbz0= -google.golang.org/grpc v1.68.0/go.mod h1:fmSPC5AsjSBCK54MyHRx48kpOti1/jRfOlwEWywNjWA= -google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= -google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/genproto v0.0.0-20241230172942-26aa7a208def h1:uz2w9bZTljGBXc3ugqrL/KOsVhQuODYyLNYXUTKrh6M= +google.golang.org/genproto v0.0.0-20241230172942-26aa7a208def/go.mod h1:zNtPaqLK0Wbf5PaSZDYDR+1t5rQoBAIMh06tpzkjvY8= +google.golang.org/genproto/googleapis/api v0.0.0-20241230172942-26aa7a208def h1:0Km0hi+g2KXbXL0+riZzSCKz23f4MmwicuEb00JeonI= +google.golang.org/genproto/googleapis/api v0.0.0-20241230172942-26aa7a208def/go.mod h1:u2DoMSpCXjrzqLdobRccQMc9wrnMAJ1DLng0a2yqM2Q= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241230172942-26aa7a208def h1:4P81qv5JXI/sDNae2ClVx88cgDDA6DPilADkG9tYKz8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241230172942-26aa7a208def/go.mod h1:bdAgzvd4kFrpykc5/AC2eLUiegK9T/qxZHD4hXYf/ho= +google.golang.org/grpc v1.69.2 h1:U3S9QEtbXC0bYNvRtcoklF3xGtLViumSYxWykJS+7AU= +google.golang.org/grpc v1.69.2/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= +google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= +google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/provider/data_source_database_catalog.go b/provider/data_source_database_catalog.go new file mode 100644 index 0000000..e634e71 --- /dev/null +++ b/provider/data_source_database_catalog.go @@ -0,0 +1,140 @@ +package provider + +import ( + "context" + "fmt" + "regexp" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + + v1pb "github.com/bytebase/bytebase/proto/generated-go/v1" + + "github.com/bytebase/terraform-provider-bytebase/api" + "github.com/bytebase/terraform-provider-bytebase/provider/internal" +) + +func dataSourceDatabaseCatalog() *schema.Resource { + return &schema.Resource{ + Description: "The database catalog data source.", + ReadContext: dataSourceDatabaseCatalogRead, + Schema: map[string]*schema.Schema{ + "database": { + Type: schema.TypeString, + Required: true, + Description: "The database full name in instances/{instance}/databases/{database} format", + ValidateDiagFunc: internal.ResourceNameValidation( + regexp.MustCompile(fmt.Sprintf("^%s%s/%s%s$", internal.InstanceNamePrefix, internal.ResourceIDPattern, internal.DatabaseIDPrefix, internal.ResourceIDPattern)), + ), + }, + "schemas": { + Computed: true, + Type: schema.TypeList, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Computed: true, + }, + "tables": { + Computed: true, + Type: schema.TypeList, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Computed: true, + }, + "classification": { + Type: schema.TypeString, + Computed: true, + Description: "The classification id", + }, + "columns": { + Computed: true, + Type: schema.TypeList, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Computed: true, + }, + "semantic_type": { + Type: schema.TypeString, + Computed: true, + Description: "The semantic type id", + }, + "classification": { + Type: schema.TypeString, + Computed: true, + Description: "The classification id", + }, + "labels": { + Type: schema.TypeMap, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } +} + +func dataSourceDatabaseCatalogRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + c := m.(api.Client) + database := d.Get("database").(string) + + catalog, err := c.GetDatabaseCatalog(ctx, database) + if err != nil { + return diag.FromErr(err) + } + + d.SetId(catalog.Name) + + return setDatabaseCatalog(d, catalog) +} + +func setDatabaseCatalog(d *schema.ResourceData, catalog *v1pb.DatabaseCatalog) diag.Diagnostics { + database := getDatabaseFullNameFromCatalog(catalog.Name) + if err := d.Set("database", database); err != nil { + return diag.Errorf("cannot set database: %s", err.Error()) + } + + schemaList := []interface{}{} + for _, schema := range catalog.Schemas { + rawSchema := map[string]interface{}{} + + tableList := []interface{}{} + for _, table := range schema.Tables { + rawTable := map[string]interface{}{} + rawTable["name"] = table.Name + rawTable["classification"] = table.Classification + + columnList := []interface{}{} + for _, column := range table.GetColumns().Columns { + rawColumn := map[string]interface{}{} + rawColumn["name"] = column.Name + rawColumn["semantic_type"] = column.SemanticType + rawColumn["classification"] = column.Classification + rawColumn["labels"] = column.Labels + columnList = append(columnList, rawColumn) + } + rawTable["columns"] = columnList + tableList = append(tableList, rawTable) + } + rawSchema["tables"] = tableList + schemaList = append(schemaList, rawSchema) + } + + if err := d.Set("schemas", schemaList); err != nil { + return diag.Errorf("cannot set schemas: %s", err.Error()) + } + return nil +} diff --git a/provider/data_source_policy.go b/provider/data_source_policy.go index 2158dde..51e475f 100644 --- a/provider/data_source_policy.go +++ b/provider/data_source_policy.go @@ -44,7 +44,6 @@ func dataSourcePolicy() *schema.Resource { Type: schema.TypeString, Required: true, ValidateFunc: validation.StringInSlice([]string{ - v1pb.PolicyType_MASKING.String(), v1pb.PolicyType_MASKING_EXCEPTION.String(), }, false), Description: "The policy type.", @@ -64,7 +63,6 @@ func dataSourcePolicy() *schema.Resource { Computed: true, Description: "Decide if the policy is enforced.", }, - "masking_policy": getMaskingPolicySchema(true), "masking_exception_policy": getMaskingExceptionPolicySchema(true), }, } @@ -119,15 +117,6 @@ func getMaskingExceptionPolicySchema(computed bool) *schema.Schema { ValidateFunc: validation.StringIsNotEmpty, Description: "The member in user:{email} or group:{email} format.", }, - "masking_level": { - Type: schema.TypeString, - Computed: computed, - Optional: true, - ValidateFunc: validation.StringInSlice([]string{ - v1pb.MaskingLevel_NONE.String(), - v1pb.MaskingLevel_PARTIAL.String(), - }, false), - }, "action": { Type: schema.TypeString, Computed: computed, @@ -151,69 +140,6 @@ func getMaskingExceptionPolicySchema(computed bool) *schema.Schema { } } -func getMaskingPolicySchema(computed bool) *schema.Schema { - return &schema.Schema{ - Computed: computed, - Optional: true, - Default: nil, - Type: schema.TypeList, - MinItems: 0, - MaxItems: 1, - Elem: &schema.Resource{ - Schema: map[string]*schema.Schema{ - "mask_data": { - MinItems: 0, - Computed: computed, - Optional: true, - Default: nil, - Type: schema.TypeList, - Elem: &schema.Resource{ - Schema: map[string]*schema.Schema{ - "schema": { - Type: schema.TypeString, - Computed: computed, - Optional: true, - }, - "table": { - Type: schema.TypeString, - Computed: computed, - Optional: true, - ValidateFunc: validation.StringIsNotEmpty, - }, - "column": { - Type: schema.TypeString, - Computed: computed, - Optional: true, - ValidateFunc: validation.StringIsNotEmpty, - }, - "full_masking_algorithm_id": { - Type: schema.TypeString, - Computed: computed, - Optional: true, - }, - "partial_masking_algorithm_id": { - Type: schema.TypeString, - Computed: computed, - Optional: true, - }, - "masking_level": { - Type: schema.TypeString, - Computed: computed, - Optional: true, - ValidateFunc: validation.StringInSlice([]string{ - v1pb.MaskingLevel_NONE.String(), - v1pb.MaskingLevel_PARTIAL.String(), - v1pb.MaskingLevel_FULL.String(), - }, false), - }, - }, - }, - }, - }, - }, - } -} - func dataSourcePolicyRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { c := m.(api.Client) @@ -245,49 +171,25 @@ func setPolicyMessage(d *schema.ResourceData, policy *v1pb.Policy) diag.Diagnost return diag.Errorf("cannot set enforce for policy: %s", err.Error()) } - if p := policy.GetMaskingPolicy(); p != nil { - if err := d.Set("masking_policy", flattenMaskingPolicy(p)); err != nil { - return diag.Errorf("cannot set masking_policy: %s", err.Error()) - } - } if p := policy.GetMaskingExceptionPolicy(); p != nil { exceptionPolicy, err := flattenMaskingExceptionPolicy(p) if err != nil { return diag.FromErr(err) } if err := d.Set("masking_exception_policy", exceptionPolicy); err != nil { - return diag.Errorf("cannot set masking_policy: %s", err.Error()) + return diag.Errorf("cannot set masking_exception_policy: %s", err.Error()) } } return nil } -func flattenMaskingPolicy(p *v1pb.MaskingPolicy) []interface{} { - maskDataList := []interface{}{} - for _, maskData := range p.MaskData { - raw := map[string]interface{}{} - raw["schema"] = maskData.Schema - raw["table"] = maskData.Table - raw["column"] = maskData.Column - raw["full_masking_algorithm_id"] = maskData.FullMaskingAlgorithmId - raw["partial_masking_algorithm_id"] = maskData.PartialMaskingAlgorithmId - raw["masking_level"] = maskData.MaskingLevel.String() - maskDataList = append(maskDataList, raw) - } - policy := map[string]interface{}{ - "mask_data": maskDataList, - } - return []interface{}{policy} -} - func flattenMaskingExceptionPolicy(p *v1pb.MaskingExceptionPolicy) ([]interface{}, error) { exceptionList := []interface{}{} for _, exception := range p.MaskingExceptions { raw := map[string]interface{}{} raw["member"] = exception.Member raw["action"] = exception.Action.String() - raw["masking_level"] = exception.MaskingLevel.String() if exception.Condition == nil || exception.Condition.Expression == "" { return nil, errors.Errorf("invalid exception policy condition") diff --git a/provider/data_source_policy_list.go b/provider/data_source_policy_list.go index 10bcf64..6f8548a 100644 --- a/provider/data_source_policy_list.go +++ b/provider/data_source_policy_list.go @@ -33,7 +33,7 @@ func dataSourcePolicyList() *schema.Resource { // project policy regexp.MustCompile(fmt.Sprintf("^%s%s$", internal.ProjectNamePrefix, internal.ResourceIDPattern)), // database policy - regexp.MustCompile(fmt.Sprintf("^%s%s%s%s$", internal.InstanceNamePrefix, internal.ResourceIDPattern, internal.DatabaseIDPrefix, internal.ResourceIDPattern)), + regexp.MustCompile(fmt.Sprintf("^%s%s/%s%s$", internal.InstanceNamePrefix, internal.ResourceIDPattern, internal.DatabaseIDPrefix, internal.ResourceIDPattern)), ), Description: "The policy parent name for the policy, support projects/{resource id}, environments/{resource id}, instances/{resource id}, or instances/{resource id}/databases/{database name}", }, @@ -62,7 +62,6 @@ func dataSourcePolicyList() *schema.Resource { Computed: true, Description: "Decide if the policy is enforced.", }, - "masking_policy": getMaskingPolicySchema(true), "masking_exception_policy": getMaskingExceptionPolicySchema(true), }, }, @@ -87,9 +86,6 @@ func dataSourcePolicyListRead(ctx context.Context, d *schema.ResourceData, m int raw["inherit_from_parent"] = policy.InheritFromParent raw["enforce"] = policy.Enforce - if p := policy.GetMaskingPolicy(); p != nil { - raw["masking_policy"] = flattenMaskingPolicy(p) - } if p := policy.GetMaskingExceptionPolicy(); p != nil { exceptionPolicy, err := flattenMaskingExceptionPolicy(p) if err != nil { diff --git a/provider/data_source_policy_list_test.go b/provider/data_source_policy_list_test.go index 7dfb26b..ae37bb4 100644 --- a/provider/data_source_policy_list_test.go +++ b/provider/data_source_policy_list_test.go @@ -28,12 +28,12 @@ func TestAccPolicyListDataSource(t *testing.T) { ), internal.GetTestStepForDataSourceList( testAccCheckPolicyResource( - "masking_policy", - "instances/test-sample-instance/databases/employee", - getMaskingPolicy("salary", "amount", v1pb.MaskingLevel_FULL), - v1pb.PolicyType_MASKING, + "masking_exception_policy", + "projects/project-sample", + getMaskingExceptionPolicy("instances/test-sample-instance/databases/employee", "salary", "amount"), + v1pb.PolicyType_MASKING_EXCEPTION, ), - "bytebase_policy.masking_policy", + "bytebase_policy.masking_exception_policy", "bytebase_policy_list", "after", "policies", diff --git a/provider/data_source_policy_test.go b/provider/data_source_policy_test.go index 06dedbb..bf1328b 100644 --- a/provider/data_source_policy_test.go +++ b/provider/data_source_policy_test.go @@ -23,24 +23,23 @@ func TestAccPolicyDataSource(t *testing.T) { { Config: testAccCheckPolicyDataSource( testAccCheckPolicyResource( - "masking_policy", - "instances/test-sample-instance/databases/employee", - getMaskingPolicy("salary", "amount", v1pb.MaskingLevel_FULL), - v1pb.PolicyType_MASKING, + "masking_exception_policy", + "projects/project-sample", + getMaskingExceptionPolicy("instances/test-sample-instance/databases/employee", "salary", "amount"), + v1pb.PolicyType_MASKING_EXCEPTION, ), - "masking_policy", - "instances/test-sample-instance/databases/employee", - "bytebase_policy.masking_policy", - v1pb.PolicyType_MASKING, + "masking_exception_policy", + "projects/project-sample", + "bytebase_policy.masking_exception_policy", + v1pb.PolicyType_MASKING_EXCEPTION, ), Check: resource.ComposeTestCheckFunc( - internal.TestCheckResourceExists("data.bytebase_policy.masking_policy"), - resource.TestCheckResourceAttr("data.bytebase_policy.masking_policy", "type", v1pb.PolicyType_MASKING.String()), - resource.TestCheckResourceAttr("data.bytebase_policy.masking_policy", "masking_policy.#", "1"), - resource.TestCheckResourceAttr("data.bytebase_policy.masking_policy", "masking_policy.0.mask_data.#", "1"), - resource.TestCheckResourceAttr("data.bytebase_policy.masking_policy", "masking_policy.0.mask_data.0.table", "salary"), - resource.TestCheckResourceAttr("data.bytebase_policy.masking_policy", "masking_policy.0.mask_data.0.column", "amount"), - resource.TestCheckResourceAttr("data.bytebase_policy.masking_policy", "masking_policy.0.mask_data.0.masking_level", v1pb.MaskingLevel_FULL.String()), + internal.TestCheckResourceExists("data.bytebase_policy.masking_exception_policy"), + resource.TestCheckResourceAttr("data.bytebase_policy.masking_exception_policy", "type", v1pb.PolicyType_MASKING_EXCEPTION.String()), + resource.TestCheckResourceAttr("data.bytebase_policy.masking_exception_policy", "masking_exception_policy.#", "1"), + resource.TestCheckResourceAttr("data.bytebase_policy.masking_exception_policy", "masking_exception_policy.0.exceptions.#", "1"), + resource.TestCheckResourceAttr("data.bytebase_policy.masking_exception_policy", "masking_exception_policy.0.exceptions.0.table", "salary"), + resource.TestCheckResourceAttr("data.bytebase_policy.masking_exception_policy", "masking_exception_policy.0.exceptions.0.column", "amount"), ), }, }, @@ -59,11 +58,11 @@ func TestAccPolicyDataSource_NotFound(t *testing.T) { Config: testAccCheckPolicyDataSource( "", "policy", - "instances/test-sample-instance/databases/employee", + "projects/project-sample", "", - v1pb.PolicyType_MASKING, + v1pb.PolicyType_MASKING_EXCEPTION, ), - ExpectError: regexp.MustCompile("Cannot found policy instances/test-sample-instance/databases/employee/policies/MASKING"), + ExpectError: regexp.MustCompile("Cannot found policy projects/project-sample/policies/MASKING_EXCEPTION"), }, }, }) diff --git a/provider/data_source_setting.go b/provider/data_source_setting.go index 91c8c4d..5b5c6f7 100644 --- a/provider/data_source_setting.go +++ b/provider/data_source_setting.go @@ -38,10 +38,11 @@ func dataSourceSetting() *schema.Resource { func getExternalApprovalSetting(computed bool) *schema.Schema { return &schema.Schema{ - Computed: computed, - Optional: true, - Default: nil, - Type: schema.TypeList, + Computed: computed, + Optional: true, + Default: nil, + Type: schema.TypeList, + Description: "Configure external nodes in the approval flow. Require ENTERPRISE subscription.", Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ "nodes": { @@ -78,10 +79,11 @@ func getExternalApprovalSetting(computed bool) *schema.Schema { func getWorkspaceApprovalSetting(computed bool) *schema.Schema { return &schema.Schema{ - Computed: computed, - Optional: true, - Default: nil, - Type: schema.TypeList, + Computed: computed, + Optional: true, + Default: nil, + Type: schema.TypeList, + Description: "Configure risk level and approval flow for different tasks. Require ENTERPRISE subscription.", Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ "rules": { diff --git a/provider/internal/mock_client.go b/provider/internal/mock_client.go index 3c28658..023c254 100644 --- a/provider/internal/mock_client.go +++ b/provider/internal/mock_client.go @@ -20,6 +20,7 @@ var policyMap map[string]*v1pb.Policy var projectMap map[string]*v1pb.Project var projectIAMMap map[string]*v1pb.IamPolicy var databaseMap map[string]*v1pb.Database +var databaseCatalogMap map[string]*v1pb.DatabaseCatalog var settingMap map[string]*v1pb.Setting var vcsProviderMap map[string]*v1pb.VCSProvider var vcsConnectorMap map[string]*v1pb.VCSConnector @@ -33,6 +34,7 @@ func init() { projectMap = map[string]*v1pb.Project{} projectIAMMap = map[string]*v1pb.IamPolicy{} databaseMap = map[string]*v1pb.Database{} + databaseCatalogMap = map[string]*v1pb.DatabaseCatalog{} settingMap = map[string]*v1pb.Setting{} vcsProviderMap = map[string]*v1pb.VCSProvider{} vcsConnectorMap = map[string]*v1pb.VCSConnector{} @@ -47,6 +49,7 @@ type mockClient struct { projectMap map[string]*v1pb.Project projectIAMMap map[string]*v1pb.IamPolicy databaseMap map[string]*v1pb.Database + databaseCatalogMap map[string]*v1pb.DatabaseCatalog settingMap map[string]*v1pb.Setting vcsProviderMap map[string]*v1pb.VCSProvider vcsConnectorMap map[string]*v1pb.VCSConnector @@ -64,6 +67,7 @@ func newMockClient(_, _, _ string) (api.Client, error) { projectMap: projectMap, projectIAMMap: projectIAMMap, databaseMap: databaseMap, + databaseCatalogMap: databaseCatalogMap, settingMap: settingMap, vcsProviderMap: vcsProviderMap, vcsConnectorMap: vcsConnectorMap, @@ -327,17 +331,6 @@ func (c *mockClient) UpsertPolicy(_ context.Context, patch *v1pb.Policy, updateM } switch policyType { - case v1pb.PolicyType_MASKING: - if !existed { - if patch.GetMaskingPolicy() == nil { - return nil, errors.Errorf("payload is required to create the policy") - } - } - if v := patch.GetMaskingPolicy(); v != nil { - policy.Policy = &v1pb.Policy_MaskingPolicy{ - MaskingPolicy: v, - } - } case v1pb.PolicyType_MASKING_EXCEPTION: if !existed { if patch.GetMaskingExceptionPolicy() == nil { @@ -419,6 +412,22 @@ func (c *mockClient) UpdateDatabase(ctx context.Context, patch *v1pb.Database, u return db, nil } +// GetDatabaseCatalog gets the database catalog by the database full name. +func (c *mockClient) GetDatabaseCatalog(_ context.Context, databaseName string) (*v1pb.DatabaseCatalog, error) { + db, ok := c.databaseCatalogMap[databaseName] + if !ok { + return nil, errors.Errorf("Cannot found database catalog %s", databaseName) + } + + return db, nil +} + +// UpdateDatabaseCatalog patches the database catalog. +func (c *mockClient) UpdateDatabaseCatalog(_ context.Context, patch *v1pb.DatabaseCatalog, updateMasks []string) (*v1pb.DatabaseCatalog, error) { + c.databaseCatalogMap[patch.Name] = patch + return patch, nil +} + // GetProject gets the project by resource id. func (c *mockClient) GetProject(_ context.Context, projectName string) (*v1pb.Project, error) { proj, ok := c.projectMap[projectName] diff --git a/provider/internal/utils.go b/provider/internal/utils.go index ba466db..5c9d75e 100644 --- a/provider/internal/utils.go +++ b/provider/internal/utils.go @@ -37,6 +37,8 @@ const ( GroupNamePrefix = "groups/" // RoleNamePrefix is the prefix for role name. RoleNamePrefix = "roles/" + // DatabaseCatalogNameSuffix is the suffix for the database catalog name. + DatabaseCatalogNameSuffix = "/catalog" // ResourceIDPattern is the pattern for resource id. ResourceIDPattern = "[a-z]([a-z0-9-]{0,61}[a-z0-9])?" ) diff --git a/provider/provider.go b/provider/provider.go index ed7a5fe..044781c 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -66,17 +66,19 @@ func NewProvider() *schema.Provider { "bytebase_user_list": dataSourceUserList(), "bytebase_group": dataSourceGroup(), "bytebase_group_list": dataSourceGroupList(), + "bytebase_database_catalog": dataSourceDatabaseCatalog(), }, ResourcesMap: map[string]*schema.Resource{ - "bytebase_environment": resourceEnvironment(), - "bytebase_instance": resourceInstance(), - "bytebase_policy": resourcePolicy(), - "bytebase_project": resourceProjct(), - "bytebase_setting": resourceSetting(), - "bytebase_vcs_provider": resourceVCSProvider(), - "bytebase_vcs_connector": resourceVCSConnector(), - "bytebase_user": resourceUser(), - "bytebase_group": resourceGroup(), + "bytebase_environment": resourceEnvironment(), + "bytebase_instance": resourceInstance(), + "bytebase_policy": resourcePolicy(), + "bytebase_project": resourceProjct(), + "bytebase_setting": resourceSetting(), + "bytebase_vcs_provider": resourceVCSProvider(), + "bytebase_vcs_connector": resourceVCSConnector(), + "bytebase_user": resourceUser(), + "bytebase_group": resourceGroup(), + "bytebase_database_catalog": resourceDatabaseCatalog(), }, } } diff --git a/provider/resource_database_catalog.go b/provider/resource_database_catalog.go new file mode 100644 index 0000000..3d83369 --- /dev/null +++ b/provider/resource_database_catalog.go @@ -0,0 +1,241 @@ +package provider + +import ( + "context" + "fmt" + "regexp" + "strings" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + "github.com/pkg/errors" + + v1pb "github.com/bytebase/bytebase/proto/generated-go/v1" + + "github.com/bytebase/terraform-provider-bytebase/api" + "github.com/bytebase/terraform-provider-bytebase/provider/internal" +) + +func resourceDatabaseCatalog() *schema.Resource { + return &schema.Resource{ + Description: "The database catalog resource.", + CreateContext: resourceDatabaseCatalogCreate, + ReadContext: resourceDatabaseCatalogRead, + UpdateContext: resourceDatabaseCatalogUpdate, + DeleteContext: resourceDatabaseCatalogDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + Schema: map[string]*schema.Schema{ + "database": { + Type: schema.TypeString, + Required: true, + Description: "The database full name in instances/{instance}/databases/{database} format", + ValidateDiagFunc: internal.ResourceNameValidation( + regexp.MustCompile(fmt.Sprintf("^%s%s/%s%s$", internal.InstanceNamePrefix, internal.ResourceIDPattern, internal.DatabaseIDPrefix, internal.ResourceIDPattern)), + ), + }, + "schemas": { + Required: true, + Type: schema.TypeList, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Optional: true, + Default: "", + }, + "tables": { + Required: true, + Type: schema.TypeList, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringIsNotEmpty, + }, + "classification": { + Type: schema.TypeString, + Optional: true, + Default: "", + Description: "The classification id", + }, + "columns": { + Required: true, + Type: schema.TypeList, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Required: true, + }, + "semantic_type": { + Type: schema.TypeString, + Optional: true, + Description: "The semantic type id", + }, + "classification": { + Type: schema.TypeString, + Optional: true, + Description: "The classification id", + }, + "labels": { + Type: schema.TypeMap, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } +} + +func resourceDatabaseCatalogRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + c := m.(api.Client) + catelogName := d.Id() + database := getDatabaseFullNameFromCatalog(catelogName) + + catalog, err := c.GetDatabaseCatalog(ctx, database) + if err != nil { + return diag.FromErr(err) + } + + return setDatabaseCatalog(d, catalog) +} + +func getDatabaseFullNameFromCatalog(catalog string) string { + return strings.TrimSuffix(catalog, internal.DatabaseCatalogNameSuffix) +} + +func resourceDatabaseCatalogCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + c := m.(api.Client) + database := d.Get("database").(string) + + catalog, err := convertToDatabaseCatalog(d) + if err != nil { + return diag.Errorf("failed to convert catalog %v with error: %v", database, err.Error()) + } + + if _, err := c.UpdateDatabaseCatalog(ctx, catalog, []string{}); err != nil { + return diag.Errorf("failed to update catalog %v with error: %v", database, err.Error()) + } + + d.SetId(catalog.Name) + + var diags diag.Diagnostics + diag := resourceDatabaseCatalogRead(ctx, d, m) + if diag != nil { + diags = append(diags, diag...) + } + + return diags +} + +func resourceDatabaseCatalogUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + c := m.(api.Client) + catelogName := d.Id() + database := getDatabaseFullNameFromCatalog(catelogName) + + catalog, err := convertToDatabaseCatalog(d) + if err != nil { + return diag.Errorf("failed to convert catalog %v with error: %v", database, err.Error()) + } + + if _, err := c.UpdateDatabaseCatalog(ctx, catalog, []string{}); err != nil { + return diag.Errorf("failed to update catalog %v with error: %v", database, err.Error()) + } + + if _, err := c.UpdateDatabaseCatalog(ctx, catalog, []string{}); err != nil { + return diag.Errorf("failed to update catalog %v with error: %v", database, err.Error()) + } + + var diags diag.Diagnostics + diag := resourceDatabaseCatalogRead(ctx, d, m) + if diag != nil { + diags = append(diags, diag...) + } + + return diags +} + +func resourceDatabaseCatalogDelete(_ context.Context, d *schema.ResourceData, _ interface{}) diag.Diagnostics { + d.SetId("") + return nil +} + +func convertToDatabaseCatalog(d *schema.ResourceData) (*v1pb.DatabaseCatalog, error) { + database, ok := d.Get("database").(string) + if !ok || database == "" { + return nil, errors.Errorf("invalid database") + } + rawSchemaList, ok := d.Get("schemas").([]interface{}) + if !ok { + return nil, errors.Errorf("invalid schemas") + } + + catalog := &v1pb.DatabaseCatalog{ + Name: fmt.Sprintf("%s%s", database, internal.DatabaseCatalogNameSuffix), + Schemas: []*v1pb.SchemaCatalog{}, + } + + for _, schema := range rawSchemaList { + rawSchema := schema.(map[string]interface{}) + schema := &v1pb.SchemaCatalog{ + Name: rawSchema["name"].(string), + } + + rawTableList, ok := rawSchema["tables"].([]interface{}) + if !ok { + return nil, errors.Errorf("invalid tables") + } + for _, table := range rawTableList { + rawTable := table.(map[string]interface{}) + table := &v1pb.TableCatalog{ + Name: rawTable["name"].(string), + Classification: rawTable["classification"].(string), + } + + columnList := []*v1pb.ColumnCatalog{} + rawColumnList, ok := rawTable["columns"].([]interface{}) + if !ok { + return nil, errors.Errorf("invalid columns") + } + for _, column := range rawColumnList { + rawColumn := column.(map[string]interface{}) + labels := map[string]string{} + for key, val := range rawColumn["labels"].(map[string]interface{}) { + labels[key] = val.(string) + } + + column := &v1pb.ColumnCatalog{ + Name: rawColumn["name"].(string), + SemanticType: rawColumn["semantic_type"].(string), + Classification: rawColumn["classification"].(string), + Labels: labels, + } + columnList = append(columnList, column) + } + + table.Kind = &v1pb.TableCatalog_Columns_{ + Columns: &v1pb.TableCatalog_Columns{ + Columns: columnList, + }, + } + + schema.Tables = append(schema.Tables, table) + } + + catalog.Schemas = append(catalog.Schemas, schema) + } + + return catalog, nil +} diff --git a/provider/resource_environment.go b/provider/resource_environment.go index e783870..35e08d6 100644 --- a/provider/resource_environment.go +++ b/provider/resource_environment.go @@ -59,7 +59,7 @@ func resourceEnvironment() *schema.Resource { v1pb.EnvironmentTier_PROTECTED.String(), v1pb.EnvironmentTier_UNPROTECTED.String(), }, false), - Description: "If marked as PROTECTED, developers cannot execute any query on this environment's databases using SQL Editor by default.", + Description: "If marked as PROTECTED, developers cannot execute any query on this environment's databases using SQL Editor by default. Require ENTERPRISE subscription.", }, }, } diff --git a/provider/resource_group.go b/provider/resource_group.go index 1056442..8b95c15 100644 --- a/provider/resource_group.go +++ b/provider/resource_group.go @@ -18,7 +18,7 @@ import ( func resourceGroup() *schema.Resource { return &schema.Resource{ - Description: "The group resource.", + Description: "The group resource. Workspace domain is required for creating groups.", ReadContext: resourceGroupRead, DeleteContext: resourceGroupDelete, CreateContext: resourceGroupCreate, diff --git a/provider/resource_policy.go b/provider/resource_policy.go index 0c4c064..d11a3fc 100644 --- a/provider/resource_policy.go +++ b/provider/resource_policy.go @@ -51,7 +51,6 @@ func resourcePolicy() *schema.Resource { Type: schema.TypeString, Required: true, ValidateFunc: validation.StringInSlice([]string{ - v1pb.PolicyType_MASKING.String(), v1pb.PolicyType_MASKING_EXCEPTION.String(), }, false), Description: "The policy type.", @@ -73,7 +72,6 @@ func resourcePolicy() *schema.Resource { Default: false, Description: "Decide if the policy should inherit from the parent.", }, - "masking_policy": getMaskingPolicySchema(false), "masking_exception_policy": getMaskingExceptionPolicySchema(false), }, } @@ -126,14 +124,6 @@ func resourcePolicyCreate(ctx context.Context, d *schema.ResourceData, m interfa } switch policyType { - case v1pb.PolicyType_MASKING: - maskingPolicy, err := convertToMaskingPolicy(d) - if err != nil { - return diag.FromErr(err) - } - patch.Policy = &v1pb.Policy_MaskingPolicy{ - MaskingPolicy: maskingPolicy, - } case v1pb.PolicyType_MASKING_EXCEPTION: maskingExceptionPolicy, err := convertToMaskingExceptionPolicy(d) if err != nil { @@ -188,16 +178,6 @@ func resourcePolicyUpdate(ctx context.Context, d *schema.ResourceData, m interfa updateMasks = append(updateMasks, "enforce") } - if d.HasChange("masking_policy") { - updateMasks = append(updateMasks, "payload") - maskingPolicy, err := convertToMaskingPolicy(d) - if err != nil { - return diag.FromErr(err) - } - patch.Policy = &v1pb.Policy_MaskingPolicy{ - MaskingPolicy: maskingPolicy, - } - } if d.HasChange("masking_exception_policy") { updateMasks = append(updateMasks, "payload") maskingExceptionPolicy, err := convertToMaskingExceptionPolicy(d) @@ -229,10 +209,6 @@ func resourcePolicyUpdate(ctx context.Context, d *schema.ResourceData, m interfa return diags } -func convertToMaskingLevel(level string) v1pb.MaskingLevel { - return v1pb.MaskingLevel(v1pb.MaskingLevel_value[level]) -} - func convertToMaskingExceptionPolicy(d *schema.ResourceData) (*v1pb.MaskingExceptionPolicy, error) { rawList, ok := d.Get("masking_exception_policy").([]interface{}) if !ok || len(rawList) != 1 { @@ -282,7 +258,6 @@ func convertToMaskingExceptionPolicy(d *schema.ResourceData) (*v1pb.MaskingExcep Action: v1pb.MaskingExceptionPolicy_MaskingException_Action( v1pb.MaskingExceptionPolicy_MaskingException_Action_value[rawException["action"].(string)], ), - MaskingLevel: convertToMaskingLevel(rawException["masking_level"].(string)), Condition: &expr.Expr{ Expression: strings.Join(expressions, " && "), }, @@ -290,29 +265,3 @@ func convertToMaskingExceptionPolicy(d *schema.ResourceData) (*v1pb.MaskingExcep } return policy, nil } - -func convertToMaskingPolicy(d *schema.ResourceData) (*v1pb.MaskingPolicy, error) { - rawList, ok := d.Get("masking_policy").([]interface{}) - if !ok || len(rawList) != 1 { - return nil, errors.Errorf("invalid masking_policy") - } - - raw := rawList[0].(map[string]interface{}) - rawMaskList := raw["mask_data"].([]interface{}) - - policy := &v1pb.MaskingPolicy{} - - for _, maskData := range rawMaskList { - rawMask := maskData.(map[string]interface{}) - policy.MaskData = append(policy.MaskData, &v1pb.MaskData{ - Schema: rawMask["schema"].(string), - Table: rawMask["table"].(string), - Column: rawMask["column"].(string), - FullMaskingAlgorithmId: rawMask["full_masking_algorithm_id"].(string), - PartialMaskingAlgorithmId: rawMask["partial_masking_algorithm_id"].(string), - MaskingLevel: convertToMaskingLevel(rawMask["masking_level"].(string)), - }) - } - - return policy, nil -} diff --git a/provider/resource_policy_test.go b/provider/resource_policy_test.go index e78d951..cc87109 100644 --- a/provider/resource_policy_test.go +++ b/provider/resource_policy_test.go @@ -22,28 +22,11 @@ func TestAccPolicy(t *testing.T) { Providers: testAccProviders, CheckDestroy: testAccCheckPolicyDestroy, Steps: []resource.TestStep{ - { - Config: testAccCheckPolicyResource( - "masking_policy", - "instances/test-sample-instance/databases/employee", - getMaskingPolicy("salary", "amount", v1pb.MaskingLevel_FULL), - v1pb.PolicyType_MASKING, - ), - Check: resource.ComposeTestCheckFunc( - internal.TestCheckResourceExists("bytebase_policy.masking_policy"), - resource.TestCheckResourceAttr("bytebase_policy.masking_policy", "type", v1pb.PolicyType_MASKING.String()), - resource.TestCheckResourceAttr("bytebase_policy.masking_policy", "masking_policy.#", "1"), - resource.TestCheckResourceAttr("bytebase_policy.masking_policy", "masking_policy.0.mask_data.#", "1"), - resource.TestCheckResourceAttr("bytebase_policy.masking_policy", "masking_policy.0.mask_data.0.table", "salary"), - resource.TestCheckResourceAttr("bytebase_policy.masking_policy", "masking_policy.0.mask_data.0.column", "amount"), - resource.TestCheckResourceAttr("bytebase_policy.masking_policy", "masking_policy.0.mask_data.0.masking_level", v1pb.MaskingLevel_FULL.String()), - ), - }, { Config: testAccCheckPolicyResource( "masking_exception_policy", "projects/project-sample", - getMaskingExceptionPolicy("instances/test-sample-instance/databases/employee", "salary", "amount", v1pb.MaskingLevel_PARTIAL), + getMaskingExceptionPolicy("instances/test-sample-instance/databases/employee", "salary", "amount"), v1pb.PolicyType_MASKING_EXCEPTION, ), Check: resource.ComposeTestCheckFunc( @@ -53,7 +36,6 @@ func TestAccPolicy(t *testing.T) { resource.TestCheckResourceAttr("bytebase_policy.masking_exception_policy", "masking_exception_policy.0.exceptions.#", "1"), resource.TestCheckResourceAttr("bytebase_policy.masking_exception_policy", "masking_exception_policy.0.exceptions.0.table", "salary"), resource.TestCheckResourceAttr("bytebase_policy.masking_exception_policy", "masking_exception_policy.0.exceptions.0.column", "amount"), - resource.TestCheckResourceAttr("bytebase_policy.masking_exception_policy", "masking_exception_policy.0.exceptions.0.masking_level", v1pb.MaskingLevel_PARTIAL.String()), ), }, }, @@ -71,31 +53,18 @@ func testAccCheckPolicyResource(identifier, parent, payload string, pType v1pb.P `, identifier, parent, pType.String(), payload) } -func getMaskingPolicy(table, column string, level v1pb.MaskingLevel) string { - return fmt.Sprintf(` - masking_policy { - mask_data { - table = "%s" - column = "%s" - masking_level = "%s" - } - } - `, table, column, level.String()) -} - -func getMaskingExceptionPolicy(database, table, column string, level v1pb.MaskingLevel) string { +func getMaskingExceptionPolicy(database, table, column string) string { return fmt.Sprintf(` masking_exception_policy { exceptions { database = "%s" table = "%s" column = "%s" - masking_level = "%s" member = "user:ed@bytebase.com" action = "QUERY" } } - `, database, table, column, level.String()) + `, database, table, column) } func testAccCheckPolicyDestroy(s *terraform.State) error { From 3ad58385ee0cd4af69aa9ae54e81519a646635b9 Mon Sep 17 00:00:00 2001 From: ecmadao Date: Tue, 7 Jan 2025 15:12:04 +0800 Subject: [PATCH 9/9] fix: lint --- provider/internal/mock_client.go | 2 +- provider/resource_policy.go | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/provider/internal/mock_client.go b/provider/internal/mock_client.go index 023c254..7502f87 100644 --- a/provider/internal/mock_client.go +++ b/provider/internal/mock_client.go @@ -423,7 +423,7 @@ func (c *mockClient) GetDatabaseCatalog(_ context.Context, databaseName string) } // UpdateDatabaseCatalog patches the database catalog. -func (c *mockClient) UpdateDatabaseCatalog(_ context.Context, patch *v1pb.DatabaseCatalog, updateMasks []string) (*v1pb.DatabaseCatalog, error) { +func (c *mockClient) UpdateDatabaseCatalog(_ context.Context, patch *v1pb.DatabaseCatalog, _ []string) (*v1pb.DatabaseCatalog, error) { c.databaseCatalogMap[patch.Name] = patch return patch, nil } diff --git a/provider/resource_policy.go b/provider/resource_policy.go index d11a3fc..ccc211d 100644 --- a/provider/resource_policy.go +++ b/provider/resource_policy.go @@ -123,8 +123,7 @@ func resourcePolicyCreate(ctx context.Context, d *schema.ResourceData, m interfa Type: policyType, } - switch policyType { - case v1pb.PolicyType_MASKING_EXCEPTION: + if policyType == v1pb.PolicyType_MASKING_EXCEPTION { maskingExceptionPolicy, err := convertToMaskingExceptionPolicy(d) if err != nil { return diag.FromErr(err)