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 b161627..bb8a57c 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,22 @@ 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) + + // 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) + // SetWorkspaceIAMPolicy sets the workspace IAM policy. + SetWorkspaceIAMPolicy(ctx context.Context, setIamPolicyRequest *v1pb.SetIamPolicyRequest) (*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/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/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..a10b62b 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. +- `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/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/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/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/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/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/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/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 d8697c7..36a58d6 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" } @@ -25,238 +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" "project_developer" { - title = "Developer" - email = "developer@bytebase.com" -} - -# Create a new project -resource "bytebase_project" "sample_project" { - depends_on = [ - bytebase_user.project_developer - ] - - resource_id = local.project_id - title = "Sample project" - key = "SAMM" - - members { - member = format("user:%s", bytebase_user.project_developer.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" + } +} 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/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..3c28658 100644 --- a/provider/internal/mock_client.go +++ b/provider/internal/mock_client.go @@ -18,36 +18,58 @@ 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 +var groupMap map[string]*v1pb.Group 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{} + groupMap = map[string]*v1pb.Group{} } 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 + groupMap map[string]*v1pb.Group + 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, + groupMap: groupMap, + workspaceIAMPolicy: &v1pb.IamPolicy{}, }, nil } @@ -184,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) @@ -220,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 @@ -474,13 +503,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 (c *mockClient) GetProjectIAMPolicy(_ context.Context, projectName string) (*v1pb.IamPolicy, error) { + iamPolicy, ok := c.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 (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. @@ -519,85 +553,262 @@ 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) + provider.Name = providerName + 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) + connector.Name = connectorName + 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 +} + +// 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(_ 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 +} + +// 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/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..31f8f72 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: 0, + Description: "The maximum number of connections.", + }, "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,14 @@ func resourceInstanceCreate(ctx context.Context, d *schema.ResourceData, m inter if externalLink != "" && externalLink != existedInstance.ExternalLink { updateMasks = append(updateMasks, "external_link") } + 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") } @@ -241,6 +272,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 +291,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 +378,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 +392,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 +454,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_project.go b/provider/resource_project.go index 49a9664..21a75f3 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, ","))) } @@ -485,18 +485,15 @@ 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 { - 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_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) } diff --git a/provider/resource_user.go b/provider/resource_user.go index c06eb12..1020d75 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, @@ -71,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": { @@ -108,7 +106,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 +212,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 +298,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 +316,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 +}