From 20f3496242ac44b6ee9a93296ef446ae1301b529 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Palet?= Date: Tue, 17 Oct 2023 15:09:02 +0200 Subject: [PATCH] Onboard MongoDB Flex user (#88) * Onboard user * Add examples and generate docs * Adjustments after review --- docs/data-sources/mongodbflex_instance.md | 68 +++ docs/data-sources/mongodbflex_user.md | 38 ++ docs/index.md | 2 + docs/resources/mongodbflex_instance.md | 84 ++++ docs/resources/mongodbflex_user.md | 45 ++ .../data-source.tf | 5 + .../stackit_mongodbflex_user/data-source.tf | 4 + .../stackit_mongodbflex_instance/resource.tf | 18 + .../stackit_mongodbflex_user/resource.tf | 7 + .../mongodbflex/mongodbflex_acc_test.go | 75 +++ .../services/mongodbflex/user/datasource.go | 232 +++++++++ .../mongodbflex/user/datasource_test.go | 138 ++++++ .../services/mongodbflex/user/resource.go | 445 ++++++++++++++++++ .../mongodbflex/user/resource_test.go | 374 +++++++++++++++ .../postgresflex/user/datasource_test.go | 133 ++++++ .../postgresflex/user/resource_test.go | 2 +- stackit/provider.go | 3 + 17 files changed, 1672 insertions(+), 1 deletion(-) create mode 100644 docs/data-sources/mongodbflex_instance.md create mode 100644 docs/data-sources/mongodbflex_user.md create mode 100644 docs/resources/mongodbflex_instance.md create mode 100644 docs/resources/mongodbflex_user.md create mode 100644 examples/data-sources/stackit_mongodbflex_instance/data-source.tf create mode 100644 examples/data-sources/stackit_mongodbflex_user/data-source.tf create mode 100644 examples/resources/stackit_mongodbflex_instance/resource.tf create mode 100644 examples/resources/stackit_mongodbflex_user/resource.tf create mode 100644 stackit/internal/services/mongodbflex/user/datasource.go create mode 100644 stackit/internal/services/mongodbflex/user/datasource_test.go create mode 100644 stackit/internal/services/mongodbflex/user/resource.go create mode 100644 stackit/internal/services/mongodbflex/user/resource_test.go create mode 100644 stackit/internal/services/postgresflex/user/datasource_test.go diff --git a/docs/data-sources/mongodbflex_instance.md b/docs/data-sources/mongodbflex_instance.md new file mode 100644 index 00000000..f4ac0925 --- /dev/null +++ b/docs/data-sources/mongodbflex_instance.md @@ -0,0 +1,68 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_mongodbflex_instance Data Source - stackit" +subcategory: "" +description: |- + MongoDB Flex instance data source schema. +--- + +# stackit_mongodbflex_instance (Data Source) + +MongoDB Flex instance data source schema. + +## Example Usage + +```terraform +data "stackit_mongodbflex_instance" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + instance_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + user_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} +``` + + +## Schema + +### Required + +- `instance_id` (String) ID of the MongoDB Flex instance. +- `project_id` (String) STACKIT project ID to which the instance is associated. + +### Read-Only + +- `acl` (List of String) The Access Control List (ACL) for the MongoDB Flex instance. +- `backup_schedule` (String) +- `flavor` (Attributes) (see [below for nested schema](#nestedatt--flavor)) +- `id` (String) Terraform's internal data source. ID. It is structured as "`project_id`,`instance_id`". +- `name` (String) Instance name. +- `options` (Attributes) (see [below for nested schema](#nestedatt--options)) +- `replicas` (Number) +- `storage` (Attributes) (see [below for nested schema](#nestedatt--storage)) +- `version` (String) + + +### Nested Schema for `flavor` + +Read-Only: + +- `cpu` (Number) +- `description` (String) +- `id` (String) +- `ram` (Number) + + + +### Nested Schema for `options` + +Read-Only: + +- `type` (String) + + + +### Nested Schema for `storage` + +Read-Only: + +- `class` (String) +- `size` (Number) diff --git a/docs/data-sources/mongodbflex_user.md b/docs/data-sources/mongodbflex_user.md new file mode 100644 index 00000000..accfd13c --- /dev/null +++ b/docs/data-sources/mongodbflex_user.md @@ -0,0 +1,38 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_mongodbflex_user Data Source - stackit" +subcategory: "" +description: |- + PostgresFlex user data source schema. +--- + +# stackit_mongodbflex_user (Data Source) + +PostgresFlex user data source schema. + +## Example Usage + +```terraform +data "stackit_mongodbflex_instance" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + instance_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} +``` + + +## Schema + +### Required + +- `instance_id` (String) ID of the PostgresFlex instance. +- `project_id` (String) STACKIT project ID to which the instance is associated. +- `user_id` (String) User ID. + +### Read-Only + +- `database` (String) +- `host` (String) +- `id` (String) Terraform's internal data source. ID. It is structured as "`project_id`,`instance_id`,`user_id`". +- `port` (Number) +- `roles` (Set of String) +- `username` (String) diff --git a/docs/index.md b/docs/index.md index 021cf45f..de469b99 100644 --- a/docs/index.md +++ b/docs/index.md @@ -120,6 +120,7 @@ Using this flow is less secure since the token is long-lived. You can provide th - `jwks_custom_endpoint` (String) Custom endpoint for the jwks API, which is used to get the json web key sets (jwks) to validate tokens when using the key flow - `logme_custom_endpoint` (String) Custom endpoint for the LogMe service - `mariadb_custom_endpoint` (String) Custom endpoint for the MariaDB service +- `mongodbflex_custom_endpoint` (String) Custom endpoint for the MongoDB Flex service - `objectstorage_custom_endpoint` (String) Custom endpoint for the Object Storage service - `opensearch_custom_endpoint` (String) Custom endpoint for the OpenSearch service - `postgresflex_custom_endpoint` (String) Custom endpoint for the PostgresFlex service @@ -130,6 +131,7 @@ Using this flow is less secure since the token is long-lived. You can provide th - `redis_custom_endpoint` (String) - `region` (String) Region will be used as the default location for regional services. Not all services require a region, some are global - `resourcemanager_custom_endpoint` (String) Custom endpoint for the Resource Manager service +- `secretsmanager_custom_endpoint` (String) Custom endpoint for the Secrets Manager service - `service_account_email` (String) Service account email. It can also be set using the environment variable STACKIT_SERVICE_ACCOUNT_EMAIL - `service_account_key` (String) Service account key used for authentication. If set alongside private key, the key flow will be used to authenticate all operations. - `service_account_key_path` (String) Path for the service account key used for authentication. If set alongside the private key, the key flow will be used to authenticate all operations. diff --git a/docs/resources/mongodbflex_instance.md b/docs/resources/mongodbflex_instance.md new file mode 100644 index 00000000..8b8d58aa --- /dev/null +++ b/docs/resources/mongodbflex_instance.md @@ -0,0 +1,84 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_mongodbflex_instance Resource - stackit" +subcategory: "" +description: |- + MongoDB Flex instance resource schema. +--- + +# stackit_mongodbflex_instance (Resource) + +MongoDB Flex instance resource schema. + +## Example Usage + +```terraform +resource "stackit_mongodbflex_instance" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-instance" + acl = ["XXX.XXX.XXX.X/XX", "XX.XXX.XX.X/XX"] + flavor = { + cpu = 1 + ram = 8 + } + replicas = 1 + storage = { + class = "class" + size = 10 + } + version = "5.0" + options = { + type = "Single" + } +} +``` + + +## Schema + +### Required + +- `acl` (List of String) The Access Control List (ACL) for the MongoDB Flex instance. +- `flavor` (Attributes) (see [below for nested schema](#nestedatt--flavor)) +- `name` (String) Instance name. +- `options` (Attributes) (see [below for nested schema](#nestedatt--options)) +- `project_id` (String) STACKIT project ID to which the instance is associated. +- `replicas` (Number) +- `storage` (Attributes) (see [below for nested schema](#nestedatt--storage)) +- `version` (String) + +### Read-Only + +- `backup_schedule` (String) +- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`instance_id`". +- `instance_id` (String) ID of the MongoDB Flex instance. + + +### Nested Schema for `flavor` + +Required: + +- `cpu` (Number) +- `ram` (Number) + +Read-Only: + +- `description` (String) +- `id` (String) + + + +### Nested Schema for `options` + +Required: + +- `type` (String) + + + +### Nested Schema for `storage` + +Required: + +- `class` (String) +- `size` (Number) diff --git a/docs/resources/mongodbflex_user.md b/docs/resources/mongodbflex_user.md new file mode 100644 index 00000000..613c546d --- /dev/null +++ b/docs/resources/mongodbflex_user.md @@ -0,0 +1,45 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_mongodbflex_user Resource - stackit" +subcategory: "" +description: |- + MongoDB Flex user resource schema. +--- + +# stackit_mongodbflex_user (Resource) + +MongoDB Flex user resource schema. + +## Example Usage + +```terraform +resource "stackit_mongodbflex_user" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + instance_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + username = "username" + roles = ["role"] + database = "database" +} +``` + + +## Schema + +### Required + +- `database` (String) +- `instance_id` (String) ID of the MongoDB Flex instance. +- `project_id` (String) STACKIT project ID to which the instance is associated. +- `roles` (Set of String) Database access levels for the user. + +### Optional + +- `username` (String) + +### Read-Only + +- `host` (String) +- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`instance_id`,`user_id`". +- `password` (String, Sensitive) +- `port` (Number) +- `user_id` (String) User ID. diff --git a/examples/data-sources/stackit_mongodbflex_instance/data-source.tf b/examples/data-sources/stackit_mongodbflex_instance/data-source.tf new file mode 100644 index 00000000..bae8590a --- /dev/null +++ b/examples/data-sources/stackit_mongodbflex_instance/data-source.tf @@ -0,0 +1,5 @@ +data "stackit_mongodbflex_instance" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + instance_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + user_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} diff --git a/examples/data-sources/stackit_mongodbflex_user/data-source.tf b/examples/data-sources/stackit_mongodbflex_user/data-source.tf new file mode 100644 index 00000000..cc6d6148 --- /dev/null +++ b/examples/data-sources/stackit_mongodbflex_user/data-source.tf @@ -0,0 +1,4 @@ +data "stackit_mongodbflex_instance" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + instance_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} diff --git a/examples/resources/stackit_mongodbflex_instance/resource.tf b/examples/resources/stackit_mongodbflex_instance/resource.tf new file mode 100644 index 00000000..537b5f95 --- /dev/null +++ b/examples/resources/stackit_mongodbflex_instance/resource.tf @@ -0,0 +1,18 @@ +resource "stackit_mongodbflex_instance" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-instance" + acl = ["XXX.XXX.XXX.X/XX", "XX.XXX.XX.X/XX"] + flavor = { + cpu = 1 + ram = 8 + } + replicas = 1 + storage = { + class = "class" + size = 10 + } + version = "5.0" + options = { + type = "Single" + } +} diff --git a/examples/resources/stackit_mongodbflex_user/resource.tf b/examples/resources/stackit_mongodbflex_user/resource.tf new file mode 100644 index 00000000..71b4b443 --- /dev/null +++ b/examples/resources/stackit_mongodbflex_user/resource.tf @@ -0,0 +1,7 @@ +resource "stackit_mongodbflex_user" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + instance_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + username = "username" + roles = ["role"] + database = "database" +} diff --git a/stackit/internal/services/mongodbflex/mongodbflex_acc_test.go b/stackit/internal/services/mongodbflex/mongodbflex_acc_test.go index bc60878a..7b5958cc 100644 --- a/stackit/internal/services/mongodbflex/mongodbflex_acc_test.go +++ b/stackit/internal/services/mongodbflex/mongodbflex_acc_test.go @@ -35,6 +35,14 @@ var instanceResource = map[string]string{ "flavor_id": "2.4", } +// User resource data +var userResource = map[string]string{ + "username": fmt.Sprintf("tf-acc-user-%s", acctest.RandStringFromCharSet(7, acctest.CharSetAlpha)), + "role": "read", + "database": "default", + "project_id": instanceResource["project_id"], +} + func configResources(version string) string { return fmt.Sprintf(` %s @@ -57,6 +65,14 @@ func configResources(version string) string { type = "%s" } } + + resource "stackit_mongodbflex_user" "user" { + project_id = stackit_mongodbflex_instance.instance.project_id + instance_id = stackit_mongodbflex_instance.instance.instance_id + username = "%s" + roles = ["%s"] + database = "%s" + } `, testutil.MongoDBFlexProviderConfig(), instanceResource["project_id"], @@ -69,6 +85,9 @@ func configResources(version string) string { instanceResource["storage_size"], version, instanceResource["options_type"], + userResource["username"], + userResource["role"], + userResource["database"], ) } @@ -96,6 +115,20 @@ func TestAccMongoDBFlexFlexResource(t *testing.T) { resource.TestCheckResourceAttr("stackit_mongodbflex_instance.instance", "storage.size", instanceResource["storage_size"]), resource.TestCheckResourceAttr("stackit_mongodbflex_instance.instance", "version", instanceResource["version"]), resource.TestCheckResourceAttr("stackit_mongodbflex_instance.instance", "options.type", instanceResource["options_type"]), + + // User + resource.TestCheckResourceAttrPair( + "stackit_mongodbflex_user.user", "project_id", + "stackit_mongodbflex_instance.instance", "project_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_mongodbflex_user.user", "instance_id", + "stackit_mongodbflex_instance.instance", "instance_id", + ), + resource.TestCheckResourceAttrSet("stackit_mongodbflex_user.user", "user_id"), + resource.TestCheckResourceAttrSet("stackit_mongodbflex_user.user", "password"), + resource.TestCheckResourceAttr("stackit_mongodbflex_user.user", "username", userResource["username"]), + resource.TestCheckResourceAttr("stackit_mongodbflex_user.user", "database", userResource["database"]), ), }, // data source @@ -107,6 +140,12 @@ func TestAccMongoDBFlexFlexResource(t *testing.T) { project_id = stackit_mongodbflex_instance.instance.project_id instance_id = stackit_mongodbflex_instance.instance.instance_id } + + data "stackit_mongodbflex_user" "user" { + project_id = stackit_mongodbflex_instance.instance.project_id + instance_id = stackit_mongodbflex_instance.instance.instance_id + user_id = stackit_mongodbflex_user.user.user_id + } `, configResources(instanceResource["version"]), ), @@ -122,6 +161,10 @@ func TestAccMongoDBFlexFlexResource(t *testing.T) { "data.stackit_mongodbflex_instance.instance", "instance_id", "stackit_mongodbflex_instance.instance", "instance_id", ), + resource.TestCheckResourceAttrPair( + "data.stackit_mongodbflex_user.user", "instance_id", + "stackit_mongodbflex_user.user", "instance_id", + ), resource.TestCheckResourceAttr("data.stackit_mongodbflex_instance.instance", "acl.#", "1"), resource.TestCheckResourceAttr("data.stackit_mongodbflex_instance.instance", "acl.0", instanceResource["acl"]), @@ -131,6 +174,16 @@ func TestAccMongoDBFlexFlexResource(t *testing.T) { resource.TestCheckResourceAttr("data.stackit_mongodbflex_instance.instance", "flavor.ram", instanceResource["flavor_ram"]), resource.TestCheckResourceAttr("data.stackit_mongodbflex_instance.instance", "replicas", instanceResource["replicas"]), resource.TestCheckResourceAttr("data.stackit_mongodbflex_instance.instance", "options.type", instanceResource["options_type"]), + + // User data + resource.TestCheckResourceAttr("data.stackit_mongodbflex_user.user", "project_id", userResource["project_id"]), + resource.TestCheckResourceAttrSet("data.stackit_mongodbflex_user.user", "user_id"), + resource.TestCheckResourceAttr("data.stackit_mongodbflex_user.user", "username", userResource["username"]), + resource.TestCheckResourceAttr("data.stackit_mongodbflex_user.user", "database", userResource["database"]), + resource.TestCheckResourceAttr("data.stackit_mongodbflex_user.user", "roles.#", "1"), + resource.TestCheckResourceAttr("data.stackit_mongodbflex_user.user", "roles.0", userResource["role"]), + resource.TestCheckResourceAttrSet("data.stackit_mongodbflex_user.user", "host"), + resource.TestCheckResourceAttrSet("data.stackit_mongodbflex_user.user", "port"), ), }, // Import @@ -151,6 +204,28 @@ func TestAccMongoDBFlexFlexResource(t *testing.T) { ImportState: true, ImportStateVerify: true, }, + { + ResourceName: "stackit_mongodbflex_user.user", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_mongodbflex_user.user"] + if !ok { + return "", fmt.Errorf("couldn't find resource stackit_mongodbflex_user.user") + } + instanceId, ok := r.Primary.Attributes["instance_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute instance_id") + } + userId, ok := r.Primary.Attributes["user_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute user_id") + } + + return fmt.Sprintf("%s,%s,%s", testutil.ProjectId, instanceId, userId), nil + }, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"password"}, + }, // Update { Config: configResources(instanceResource["version_updated"]), diff --git a/stackit/internal/services/mongodbflex/user/datasource.go b/stackit/internal/services/mongodbflex/user/datasource.go new file mode 100644 index 00000000..cbb945cc --- /dev/null +++ b/stackit/internal/services/mongodbflex/user/datasource.go @@ -0,0 +1,232 @@ +package mongodbflex + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" + + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &userDataSource{} +) + +type DataSourceModel struct { + Id types.String `tfsdk:"id"` // needed by TF + UserId types.String `tfsdk:"user_id"` + InstanceId types.String `tfsdk:"instance_id"` + ProjectId types.String `tfsdk:"project_id"` + Username types.String `tfsdk:"username"` + Database types.String `tfsdk:"database"` + Roles types.Set `tfsdk:"roles"` + Host types.String `tfsdk:"host"` + Port types.Int64 `tfsdk:"port"` +} + +// NewUserDataSource is a helper function to simplify the provider implementation. +func NewUserDataSource() datasource.DataSource { + return &userDataSource{} +} + +// userDataSource is the data source implementation. +type userDataSource struct { + client *mongodbflex.APIClient +} + +// Metadata returns the data source type name. +func (r *userDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_mongodbflex_user" +} + +// Configure adds the provider configured client to the data source. +func (r *userDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + providerData, ok := req.ProviderData.(core.ProviderData) + if !ok { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData)) + return + } + + var apiClient *mongodbflex.APIClient + var err error + if providerData.PostgresFlexCustomEndpoint != "" { + apiClient, err = mongodbflex.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithEndpoint(providerData.PostgresFlexCustomEndpoint), + ) + } else { + apiClient, err = mongodbflex.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithRegion(providerData.Region), + ) + } + + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Configuring client: %v", err)) + return + } + + r.client = apiClient + tflog.Info(ctx, "PostgresFlex user client configured") +} + +// Schema defines the schema for the data source. +func (r *userDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + descriptions := map[string]string{ + "main": "PostgresFlex user data source schema.", + "id": "Terraform's internal data source. ID. It is structured as \"`project_id`,`instance_id`,`user_id`\".", + "user_id": "User ID.", + "instance_id": "ID of the PostgresFlex instance.", + "project_id": "STACKIT project ID to which the instance is associated.", + } + + resp.Schema = schema.Schema{ + Description: descriptions["main"], + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: descriptions["id"], + Computed: true, + }, + "user_id": schema.StringAttribute{ + Description: descriptions["user_id"], + Required: true, + Validators: []validator.String{ + validate.NoSeparator(), + }, + }, + "instance_id": schema.StringAttribute{ + Description: descriptions["instance_id"], + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "project_id": schema.StringAttribute{ + Description: descriptions["project_id"], + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "username": schema.StringAttribute{ + Computed: true, + }, + "roles": schema.SetAttribute{ + ElementType: types.StringType, + Computed: true, + }, + "database": schema.StringAttribute{ + Computed: true, + }, + "host": schema.StringAttribute{ + Computed: true, + }, + "port": schema.Int64Attribute{ + Computed: true, + }, + }, + } +} + +// Read refreshes the Terraform state with the latest data. +func (r *userDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var model DataSourceModel + diags := req.Config.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + projectId := model.ProjectId.ValueString() + instanceId := model.InstanceId.ValueString() + userId := model.UserId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "instance_id", instanceId) + ctx = tflog.SetField(ctx, "user_id", userId) + + recordSetResp, err := r.client.GetUser(ctx, projectId, instanceId, userId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading user", fmt.Sprintf("Calling API: %v", err)) + return + } + + // Map response body to schema and populate Computed attribute values + err = mapDataSourceFields(recordSetResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading user", fmt.Sprintf("Processing API payload: %v", err)) + return + } + + // Set refreshed state + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "PostgresFlex user read") +} + +func mapDataSourceFields(userResp *mongodbflex.GetUserResponse, model *DataSourceModel) error { + if userResp == nil || userResp.Item == nil { + return fmt.Errorf("response is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + user := userResp.Item + + var userId string + if model.UserId.ValueString() != "" { + userId = model.UserId.ValueString() + } else if user.Id != nil { + userId = *user.Id + } else { + return fmt.Errorf("user id not present") + } + idParts := []string{ + model.ProjectId.ValueString(), + model.InstanceId.ValueString(), + userId, + } + model.Id = types.StringValue( + strings.Join(idParts, core.Separator), + ) + model.UserId = types.StringValue(userId) + model.Username = types.StringPointerValue(user.Username) + model.Database = types.StringPointerValue(user.Database) + + if user.Roles == nil { + model.Roles = types.SetNull(types.StringType) + } else { + roles := []attr.Value{} + for _, role := range *user.Roles { + roles = append(roles, types.StringValue(role)) + } + rolesSet, diags := types.SetValue(types.StringType, roles) + if diags.HasError() { + return fmt.Errorf("mapping roles: %w", core.DiagsToError(diags)) + } + model.Roles = rolesSet + } + model.Host = types.StringPointerValue(user.Host) + model.Port = conversion.ToTypeInt64(user.Port) + return nil +} diff --git a/stackit/internal/services/mongodbflex/user/datasource_test.go b/stackit/internal/services/mongodbflex/user/datasource_test.go new file mode 100644 index 00000000..0121a8cc --- /dev/null +++ b/stackit/internal/services/mongodbflex/user/datasource_test.go @@ -0,0 +1,138 @@ +package mongodbflex + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" +) + +func TestMapDataSourceFields(t *testing.T) { + tests := []struct { + description string + input *mongodbflex.GetUserResponse + expected DataSourceModel + isValid bool + }{ + { + "default_values", + &mongodbflex.GetUserResponse{ + Item: &mongodbflex.InstanceResponseUser{}, + }, + DataSourceModel{ + Id: types.StringValue("pid,iid,uid"), + UserId: types.StringValue("uid"), + InstanceId: types.StringValue("iid"), + ProjectId: types.StringValue("pid"), + Username: types.StringNull(), + Database: types.StringNull(), + Roles: types.SetNull(types.StringType), + Host: types.StringNull(), + Port: types.Int64Null(), + }, + true, + }, + { + "simple_values", + &mongodbflex.GetUserResponse{ + Item: &mongodbflex.InstanceResponseUser{ + Roles: &[]string{ + "role_1", + "role_2", + "", + }, + Username: utils.Ptr("username"), + Database: utils.Ptr("database"), + Host: utils.Ptr("host"), + Port: utils.Ptr(int32(1234)), + }, + }, + DataSourceModel{ + Id: types.StringValue("pid,iid,uid"), + UserId: types.StringValue("uid"), + InstanceId: types.StringValue("iid"), + ProjectId: types.StringValue("pid"), + Username: types.StringValue("username"), + Database: types.StringValue("database"), + Roles: types.SetValueMust(types.StringType, []attr.Value{ + types.StringValue("role_1"), + types.StringValue("role_2"), + types.StringValue(""), + }), + Host: types.StringValue("host"), + Port: types.Int64Value(1234), + }, + true, + }, + { + "null_fields_and_int_conversions", + &mongodbflex.GetUserResponse{ + Item: &mongodbflex.InstanceResponseUser{ + Id: utils.Ptr("uid"), + Roles: &[]string{}, + Username: nil, + Database: nil, + Host: nil, + Port: utils.Ptr(int32(2123456789)), + }, + }, + DataSourceModel{ + Id: types.StringValue("pid,iid,uid"), + UserId: types.StringValue("uid"), + InstanceId: types.StringValue("iid"), + ProjectId: types.StringValue("pid"), + Username: types.StringNull(), + Database: types.StringNull(), + Roles: types.SetValueMust(types.StringType, []attr.Value{}), + Host: types.StringNull(), + Port: types.Int64Value(2123456789), + }, + true, + }, + { + "nil_response", + nil, + DataSourceModel{}, + false, + }, + { + "nil_response_2", + &mongodbflex.GetUserResponse{}, + DataSourceModel{}, + false, + }, + { + "no_resource_id", + &mongodbflex.GetUserResponse{ + Item: &mongodbflex.InstanceResponseUser{}, + }, + DataSourceModel{}, + false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + state := &DataSourceModel{ + ProjectId: tt.expected.ProjectId, + InstanceId: tt.expected.InstanceId, + UserId: tt.expected.UserId, + } + err := mapDataSourceFields(tt.input, state) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(state, &tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} diff --git a/stackit/internal/services/mongodbflex/user/resource.go b/stackit/internal/services/mongodbflex/user/resource.go new file mode 100644 index 00000000..bd13015d --- /dev/null +++ b/stackit/internal/services/mongodbflex/user/resource.go @@ -0,0 +1,445 @@ +package mongodbflex + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ resource.Resource = &userResource{} + _ resource.ResourceWithConfigure = &userResource{} + _ resource.ResourceWithImportState = &userResource{} +) + +type Model struct { + Id types.String `tfsdk:"id"` // needed by TF + UserId types.String `tfsdk:"user_id"` + InstanceId types.String `tfsdk:"instance_id"` + ProjectId types.String `tfsdk:"project_id"` + Username types.String `tfsdk:"username"` + Roles types.Set `tfsdk:"roles"` + Database types.String `tfsdk:"database"` + Password types.String `tfsdk:"password"` + Host types.String `tfsdk:"host"` + Port types.Int64 `tfsdk:"port"` +} + +// NewUserResource is a helper function to simplify the provider implementation. +func NewUserResource() resource.Resource { + return &userResource{} +} + +// userResource is the resource implementation. +type userResource struct { + client *mongodbflex.APIClient +} + +// Metadata returns the resource type name. +func (r *userResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_mongodbflex_user" +} + +// Configure adds the provider configured client to the resource. +func (r *userResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + providerData, ok := req.ProviderData.(core.ProviderData) + if !ok { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData)) + return + } + + var apiClient *mongodbflex.APIClient + var err error + if providerData.MongoDBFlexCustomEndpoint != "" { + apiClient, err = mongodbflex.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithEndpoint(providerData.MongoDBFlexCustomEndpoint), + ) + } else { + apiClient, err = mongodbflex.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithRegion(providerData.Region), + ) + } + + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Configuring client: %v", err)) + return + } + + r.client = apiClient + tflog.Info(ctx, "MongoDB Flex user client configured") +} + +// Schema defines the schema for the resource. +func (r *userResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + descriptions := map[string]string{ + "main": "MongoDB Flex user resource schema.", + "id": "Terraform's internal resource ID. It is structured as \"`project_id`,`instance_id`,`user_id`\".", + "user_id": "User ID.", + "instance_id": "ID of the MongoDB Flex instance.", + "project_id": "STACKIT project ID to which the instance is associated.", + "roles": "Database access levels for the user.", + } + + resp.Schema = schema.Schema{ + Description: descriptions["main"], + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: descriptions["id"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "user_id": schema.StringAttribute{ + Description: descriptions["user_id"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + validate.NoSeparator(), + }, + }, + "instance_id": schema.StringAttribute{ + Description: descriptions["instance_id"], + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "project_id": schema.StringAttribute{ + Description: descriptions["project_id"], + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "username": schema.StringAttribute{ + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "roles": schema.SetAttribute{ + Description: descriptions["roles"], + ElementType: types.StringType, + Required: true, + Validators: []validator.Set{ + setvalidator.ValueStringsAre( + stringvalidator.OneOf("read", "readWrite"), + ), + }, + }, + "database": schema.StringAttribute{ + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "password": schema.StringAttribute{ + Computed: true, + Sensitive: true, + }, + "host": schema.StringAttribute{ + Computed: true, + }, + "port": schema.Int64Attribute{ + Computed: true, + }, + }, + } +} + +// Create creates the resource and sets the initial Terraform state. +func (r *userResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + diags := req.Plan.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + projectId := model.ProjectId.ValueString() + instanceId := model.InstanceId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "instance_id", instanceId) + + var roles []string + if !(model.Roles.IsNull() || model.Roles.IsUnknown()) { + diags = model.Roles.ElementsAs(ctx, &roles, false) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + } + + // Generate API request body from model + payload, err := toCreatePayload(&model, roles) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating user", fmt.Sprintf("Creating API payload: %v", err)) + return + } + // Create new user + userResp, err := r.client.CreateUser(ctx, projectId, instanceId).CreateUserPayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating user", fmt.Sprintf("Calling API: %v", err)) + return + } + if userResp == nil || userResp.Item == nil || userResp.Item.Id == nil || *userResp.Item.Id == "" { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating user", "API didn't return user ID. A user might have been created") + return + } + userId := *userResp.Item.Id + ctx = tflog.SetField(ctx, "user_id", userId) + + // Map response body to schema + err = mapFieldsCreate(userResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating user", fmt.Sprintf("Processing API payload: %v", err)) + return + } + // Set state to fully populated data + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "MongoDB Flex user created") +} + +// Read refreshes the Terraform state with the latest data. +func (r *userResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + projectId := model.ProjectId.ValueString() + instanceId := model.InstanceId.ValueString() + userId := model.UserId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "instance_id", instanceId) + ctx = tflog.SetField(ctx, "user_id", userId) + + recordSetResp, err := r.client.GetUser(ctx, projectId, instanceId, userId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading user", fmt.Sprintf("Calling API: %v", err)) + return + } + + // Map response body to schema + err = mapFields(recordSetResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading user", fmt.Sprintf("Processing API payload: %v", err)) + return + } + + // Set refreshed state + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "MongoDB Flex user read") +} + +// Update updates the resource and sets the updated Terraform state on success. +func (r *userResource) Update(ctx context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform + // Update shouldn't be called + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating user", "User can't be updated") +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *userResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve values from plan + var model Model + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + projectId := model.ProjectId.ValueString() + instanceId := model.InstanceId.ValueString() + userId := model.UserId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "instance_id", instanceId) + ctx = tflog.SetField(ctx, "user_id", userId) + + // Delete user + err := r.client.DeleteUser(ctx, projectId, instanceId, userId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting user", fmt.Sprintf("Calling API: %v", err)) + return + } + tflog.Info(ctx, "MongoDB Flex user deleted") +} + +// ImportState imports a resource into the Terraform state on success. +// The expected format of the resource import identifier is: project_id,zone_id,record_set_id +func (r *userResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + idParts := strings.Split(req.ID, core.Separator) + if len(idParts) != 3 || idParts[0] == "" || idParts[1] == "" || idParts[2] == "" { + core.LogAndAddError(ctx, &resp.Diagnostics, + "Error importing user", + fmt.Sprintf("Expected import identifier with format [project_id],[instance_id],[user_id], got %q", req.ID), + ) + return + } + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), idParts[0])...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("instance_id"), idParts[1])...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("user_id"), idParts[2])...) + core.LogAndAddWarning(ctx, &resp.Diagnostics, + "Postgresflex user imported with empty password", + "The user password is not imported as it is only available upon creation of a new user. The password field will be empty.", + ) + tflog.Info(ctx, "Postgresflex user state imported") +} + +func mapFieldsCreate(userResp *mongodbflex.CreateUserResponse, model *Model) error { + if userResp == nil || userResp.Item == nil { + return fmt.Errorf("response is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + user := userResp.Item + + if user.Id == nil { + return fmt.Errorf("user id not present") + } + userId := *user.Id + idParts := []string{ + model.ProjectId.ValueString(), + model.InstanceId.ValueString(), + userId, + } + model.Id = types.StringValue( + strings.Join(idParts, core.Separator), + ) + model.UserId = types.StringValue(userId) + model.Username = types.StringPointerValue(user.Username) + model.Database = types.StringPointerValue(user.Database) + + if user.Password == nil { + return fmt.Errorf("user password not present") + } + model.Password = types.StringValue(*user.Password) + + if user.Roles == nil { + model.Roles = types.SetNull(types.StringType) + } else { + roles := []attr.Value{} + for _, role := range *user.Roles { + roles = append(roles, types.StringValue(role)) + } + rolesSet, diags := types.SetValue(types.StringType, roles) + if diags.HasError() { + return fmt.Errorf("mapping roles: %w", core.DiagsToError(diags)) + } + model.Roles = rolesSet + } + model.Host = types.StringPointerValue(user.Host) + model.Port = conversion.ToTypeInt64(user.Port) + return nil +} + +func mapFields(userResp *mongodbflex.GetUserResponse, model *Model) error { + if userResp == nil || userResp.Item == nil { + return fmt.Errorf("response is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + user := userResp.Item + + var userId string + if model.UserId.ValueString() != "" { + userId = model.UserId.ValueString() + } else if user.Id != nil { + userId = *user.Id + } else { + return fmt.Errorf("user id not present") + } + idParts := []string{ + model.ProjectId.ValueString(), + model.InstanceId.ValueString(), + userId, + } + model.Id = types.StringValue( + strings.Join(idParts, core.Separator), + ) + model.UserId = types.StringValue(userId) + model.Username = types.StringPointerValue(user.Username) + model.Database = types.StringPointerValue(user.Database) + + if user.Roles == nil { + model.Roles = types.SetNull(types.StringType) + } else { + roles := []attr.Value{} + for _, role := range *user.Roles { + roles = append(roles, types.StringValue(role)) + } + rolesSet, diags := types.SetValue(types.StringType, roles) + if diags.HasError() { + return fmt.Errorf("mapping roles: %w", core.DiagsToError(diags)) + } + model.Roles = rolesSet + } + model.Host = types.StringPointerValue(user.Host) + model.Port = conversion.ToTypeInt64(user.Port) + return nil +} + +func toCreatePayload(model *Model, roles []string) (*mongodbflex.CreateUserPayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + if roles == nil { + return nil, fmt.Errorf("nil roles") + } + + return &mongodbflex.CreateUserPayload{ + Roles: &roles, + Username: model.Username.ValueStringPointer(), + Database: model.Database.ValueStringPointer(), + }, nil +} diff --git a/stackit/internal/services/mongodbflex/user/resource_test.go b/stackit/internal/services/mongodbflex/user/resource_test.go new file mode 100644 index 00000000..c457f9c0 --- /dev/null +++ b/stackit/internal/services/mongodbflex/user/resource_test.go @@ -0,0 +1,374 @@ +package mongodbflex + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" +) + +func TestMapFieldsCreate(t *testing.T) { + tests := []struct { + description string + input *mongodbflex.CreateUserResponse + expected Model + isValid bool + }{ + { + "default_values", + &mongodbflex.CreateUserResponse{ + Item: &mongodbflex.InstanceUser{ + Id: utils.Ptr("uid"), + Password: utils.Ptr(""), + }, + }, + Model{ + Id: types.StringValue("pid,iid,uid"), + UserId: types.StringValue("uid"), + InstanceId: types.StringValue("iid"), + ProjectId: types.StringValue("pid"), + Username: types.StringNull(), + Database: types.StringNull(), + Roles: types.SetNull(types.StringType), + Password: types.StringValue(""), + Host: types.StringNull(), + Port: types.Int64Null(), + }, + true, + }, + { + "simple_values", + &mongodbflex.CreateUserResponse{ + Item: &mongodbflex.InstanceUser{ + Id: utils.Ptr("uid"), + Roles: &[]string{ + "role_1", + "role_2", + "", + }, + Username: utils.Ptr("username"), + Database: utils.Ptr("database"), + Password: utils.Ptr("password"), + Host: utils.Ptr("host"), + Port: utils.Ptr(int32(1234)), + }, + }, + Model{ + Id: types.StringValue("pid,iid,uid"), + UserId: types.StringValue("uid"), + InstanceId: types.StringValue("iid"), + ProjectId: types.StringValue("pid"), + Username: types.StringValue("username"), + Database: types.StringValue("database"), + Roles: types.SetValueMust(types.StringType, []attr.Value{ + types.StringValue("role_1"), + types.StringValue("role_2"), + types.StringValue(""), + }), + Password: types.StringValue("password"), + Host: types.StringValue("host"), + Port: types.Int64Value(1234), + }, + true, + }, + { + "null_fields_and_int_conversions", + &mongodbflex.CreateUserResponse{ + Item: &mongodbflex.InstanceUser{ + Id: utils.Ptr("uid"), + Roles: &[]string{}, + Username: nil, + Database: nil, + Password: utils.Ptr(""), + Host: nil, + Port: utils.Ptr(int32(2123456789)), + }, + }, + Model{ + Id: types.StringValue("pid,iid,uid"), + UserId: types.StringValue("uid"), + InstanceId: types.StringValue("iid"), + ProjectId: types.StringValue("pid"), + Username: types.StringNull(), + Database: types.StringNull(), + Roles: types.SetValueMust(types.StringType, []attr.Value{}), + Password: types.StringValue(""), + Host: types.StringNull(), + Port: types.Int64Value(2123456789), + }, + true, + }, + { + "nil_response", + nil, + Model{}, + false, + }, + { + "nil_response_2", + &mongodbflex.CreateUserResponse{}, + Model{}, + false, + }, + { + "no_resource_id", + &mongodbflex.CreateUserResponse{ + Item: &mongodbflex.InstanceUser{}, + }, + Model{}, + false, + }, + { + "no_password", + &mongodbflex.CreateUserResponse{ + Item: &mongodbflex.InstanceUser{ + Id: utils.Ptr("uid"), + }, + }, + Model{}, + false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + state := &Model{ + ProjectId: tt.expected.ProjectId, + InstanceId: tt.expected.InstanceId, + } + err := mapFieldsCreate(tt.input, state) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(state, &tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func TestMapFields(t *testing.T) { + tests := []struct { + description string + input *mongodbflex.GetUserResponse + expected Model + isValid bool + }{ + { + "default_values", + &mongodbflex.GetUserResponse{ + Item: &mongodbflex.InstanceResponseUser{}, + }, + Model{ + Id: types.StringValue("pid,iid,uid"), + UserId: types.StringValue("uid"), + InstanceId: types.StringValue("iid"), + ProjectId: types.StringValue("pid"), + Username: types.StringNull(), + Database: types.StringNull(), + Roles: types.SetNull(types.StringType), + Host: types.StringNull(), + Port: types.Int64Null(), + }, + true, + }, + { + "simple_values", + &mongodbflex.GetUserResponse{ + Item: &mongodbflex.InstanceResponseUser{ + Roles: &[]string{ + "role_1", + "role_2", + "", + }, + Username: utils.Ptr("username"), + Database: utils.Ptr("database"), + Host: utils.Ptr("host"), + Port: utils.Ptr(int32(1234)), + }, + }, + Model{ + Id: types.StringValue("pid,iid,uid"), + UserId: types.StringValue("uid"), + InstanceId: types.StringValue("iid"), + ProjectId: types.StringValue("pid"), + Username: types.StringValue("username"), + Database: types.StringValue("database"), + Roles: types.SetValueMust(types.StringType, []attr.Value{ + types.StringValue("role_1"), + types.StringValue("role_2"), + types.StringValue(""), + }), + Host: types.StringValue("host"), + Port: types.Int64Value(1234), + }, + true, + }, + { + "null_fields_and_int_conversions", + &mongodbflex.GetUserResponse{ + Item: &mongodbflex.InstanceResponseUser{ + Id: utils.Ptr("uid"), + Roles: &[]string{}, + Username: nil, + Database: nil, + Host: nil, + Port: utils.Ptr(int32(2123456789)), + }, + }, + Model{ + Id: types.StringValue("pid,iid,uid"), + UserId: types.StringValue("uid"), + InstanceId: types.StringValue("iid"), + ProjectId: types.StringValue("pid"), + Username: types.StringNull(), + Database: types.StringNull(), + Roles: types.SetValueMust(types.StringType, []attr.Value{}), + Host: types.StringNull(), + Port: types.Int64Value(2123456789), + }, + true, + }, + { + "nil_response", + nil, + Model{}, + false, + }, + { + "nil_response_2", + &mongodbflex.GetUserResponse{}, + Model{}, + false, + }, + { + "no_resource_id", + &mongodbflex.GetUserResponse{ + Item: &mongodbflex.InstanceResponseUser{}, + }, + Model{}, + false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + state := &Model{ + ProjectId: tt.expected.ProjectId, + InstanceId: tt.expected.InstanceId, + UserId: tt.expected.UserId, + } + err := mapFields(tt.input, state) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(state, &tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func TestToCreatePayload(t *testing.T) { + tests := []struct { + description string + input *Model + inputRoles []string + expected *mongodbflex.CreateUserPayload + isValid bool + }{ + { + "default_values", + &Model{}, + []string{}, + &mongodbflex.CreateUserPayload{ + Roles: &[]string{}, + Username: nil, + Database: nil, + }, + true, + }, + { + "default_values", + &Model{ + Username: types.StringValue("username"), + Database: types.StringValue("database"), + }, + []string{ + "role_1", + "role_2", + }, + &mongodbflex.CreateUserPayload{ + Roles: &[]string{ + "role_1", + "role_2", + }, + Username: utils.Ptr("username"), + Database: utils.Ptr("database"), + }, + true, + }, + { + "null_fields_and_int_conversions", + &Model{ + Username: types.StringNull(), + Database: types.StringNull(), + }, + []string{ + "", + }, + &mongodbflex.CreateUserPayload{ + Roles: &[]string{ + "", + }, + Username: nil, + Database: nil, + }, + true, + }, + { + "nil_model", + nil, + []string{}, + nil, + false, + }, + { + "nil_roles", + &Model{}, + nil, + nil, + false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output, err := toCreatePayload(tt.input, tt.inputRoles) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(output, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} diff --git a/stackit/internal/services/postgresflex/user/datasource_test.go b/stackit/internal/services/postgresflex/user/datasource_test.go new file mode 100644 index 00000000..5f7d8aaa --- /dev/null +++ b/stackit/internal/services/postgresflex/user/datasource_test.go @@ -0,0 +1,133 @@ +package postgresflex + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" +) + +func TestMapDataSourceFields(t *testing.T) { + tests := []struct { + description string + input *postgresflex.UserResponse + expected DataSourceModel + isValid bool + }{ + { + "default_values", + &postgresflex.UserResponse{ + Item: &postgresflex.UserResponseUser{}, + }, + DataSourceModel{ + Id: types.StringValue("pid,iid,uid"), + UserId: types.StringValue("uid"), + InstanceId: types.StringValue("iid"), + ProjectId: types.StringValue("pid"), + Username: types.StringNull(), + Roles: types.SetNull(types.StringType), + Host: types.StringNull(), + Port: types.Int64Null(), + }, + true, + }, + { + "simple_values", + &postgresflex.UserResponse{ + Item: &postgresflex.UserResponseUser{ + Roles: &[]string{ + "role_1", + "role_2", + "", + }, + Username: utils.Ptr("username"), + Host: utils.Ptr("host"), + Port: utils.Ptr(int32(1234)), + }, + }, + DataSourceModel{ + Id: types.StringValue("pid,iid,uid"), + UserId: types.StringValue("uid"), + InstanceId: types.StringValue("iid"), + ProjectId: types.StringValue("pid"), + Username: types.StringValue("username"), + Roles: types.SetValueMust(types.StringType, []attr.Value{ + types.StringValue("role_1"), + types.StringValue("role_2"), + types.StringValue(""), + }), + Host: types.StringValue("host"), + Port: types.Int64Value(1234), + }, + true, + }, + { + "null_fields_and_int_conversions", + &postgresflex.UserResponse{ + Item: &postgresflex.UserResponseUser{ + Id: utils.Ptr("uid"), + Roles: &[]string{}, + Username: nil, + Host: nil, + Port: utils.Ptr(int32(2123456789)), + }, + }, + DataSourceModel{ + Id: types.StringValue("pid,iid,uid"), + UserId: types.StringValue("uid"), + InstanceId: types.StringValue("iid"), + ProjectId: types.StringValue("pid"), + Username: types.StringNull(), + Roles: types.SetValueMust(types.StringType, []attr.Value{}), + Host: types.StringNull(), + Port: types.Int64Value(2123456789), + }, + true, + }, + { + "nil_response", + nil, + DataSourceModel{}, + false, + }, + { + "nil_response_2", + &postgresflex.UserResponse{}, + DataSourceModel{}, + false, + }, + { + "no_resource_id", + &postgresflex.UserResponse{ + Item: &postgresflex.UserResponseUser{}, + }, + DataSourceModel{}, + false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + state := &DataSourceModel{ + ProjectId: tt.expected.ProjectId, + InstanceId: tt.expected.InstanceId, + UserId: tt.expected.UserId, + } + err := mapDataSourceFields(tt.input, state) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(state, &tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} diff --git a/stackit/internal/services/postgresflex/user/resource_test.go b/stackit/internal/services/postgresflex/user/resource_test.go index 56d6115d..a9800eb8 100644 --- a/stackit/internal/services/postgresflex/user/resource_test.go +++ b/stackit/internal/services/postgresflex/user/resource_test.go @@ -150,7 +150,7 @@ func TestMapFieldsCreate(t *testing.T) { } } -func TestMapCreate(t *testing.T) { +func TestMapFields(t *testing.T) { tests := []struct { description string input *postgresflex.UserResponse diff --git a/stackit/provider.go b/stackit/provider.go index e2859e89..537ca546 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -19,6 +19,7 @@ import ( mariaDBCredential "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/mariadb/credential" mariaDBInstance "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/mariadb/instance" mongoDBFlexInstance "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/mongodbflex/instance" + mongoDBFlexUser "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/mongodbflex/user" objectStorageBucket "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/objectstorage/bucket" objecStorageCredential "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/objectstorage/credential" objecStorageCredentialsGroup "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/objectstorage/credentialsgroup" @@ -335,6 +336,7 @@ func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource mariaDBInstance.NewInstanceDataSource, mariaDBCredential.NewCredentialDataSource, mongoDBFlexInstance.NewInstanceDataSource, + mongoDBFlexUser.NewUserDataSource, objectStorageBucket.NewBucketDataSource, objecStorageCredentialsGroup.NewCredentialsGroupDataSource, objecStorageCredential.NewCredentialDataSource, @@ -367,6 +369,7 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource { mariaDBInstance.NewInstanceResource, mariaDBCredential.NewCredentialResource, mongoDBFlexInstance.NewInstanceResource, + mongoDBFlexUser.NewUserResource, objectStorageBucket.NewBucketResource, objecStorageCredentialsGroup.NewCredentialsGroupResource, objecStorageCredential.NewCredentialResource,