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
+}