From 6fd273a614715c7980d16e4d5e05d5e3f472cdd3 Mon Sep 17 00:00:00 2001 From: Viktor Csomor Date: Thu, 9 Jan 2025 18:06:35 +0100 Subject: [PATCH] CDPCP-13591 Cloudera Data Warehouse - Data Visualization Support (#192) Running `terraform apply -auto-apply`: ``` Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # cdp_dw_data_visualization.viq-terraform-example will be created + resource "cdp_dw_data_visualization" "viq-terraform-example" { + admin_groups = [ + "dwx-viz", ] + cluster_id = "env-v5sxzr" + id = (known after apply) + image_version = (known after apply) + last_updated = (known after apply) + name = "viq-terraform-test" + resource_template = "viz-low" + status = (known after apply) + user_groups = [ + "dwx-viz", ] } Plan: 1 to add, 0 to change, 0 to destroy. cdp_dw_data_visualization.viq-terraform-example: Creating... cdp_dw_data_visualization.viq-terraform-example: Still creating... [10s elapsed] .... cdp_dw_data_visualization.viq-terraform-example: Still creating... [1m30s elapsed] cdp_dw_data_visualization.viq-terraform-example: Creation complete after 1m35s [id=viz-1734702099-rc6s] Apply complete! Resources: 1 added, 0 changed, 0 destroyed. ``` ---- Running `terraform apply -auto-apply -destroy`: ``` Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: - destroy Terraform will perform the following actions: # cdp_dw_data_visualization.viq-terraform-example will be destroyed - resource "cdp_dw_data_visualization" "viq-terraform-example" { - admin_groups = [ - "dwx-viz", ] -> null - cluster_id = "env-v5sxzr" -> null - id = "viz-1734702099-rc6s" -> null - image_version = "7.2.7-b48" -> null - last_updated = "Friday, 20-Dec-24 14:43:14 CET" -> null - name = "viq-terraform-test" -> null - resource_template = "viz-low" -> null - status = "Running" -> null - user_groups = [ - "dwx-viz", ] -> null } Plan: 0 to add, 0 to change, 1 to destroy. cdp_dw_data_visualization.viq-terraform-example: Destroying... [id=viz-1734702099-rc6s] cdp_dw_data_visualization.viq-terraform-example: Still destroying... [id=viz-1734702099-rc6s, 10s elapsed] cdp_dw_data_visualization.viq-terraform-example: Still destroying... [id=viz-1734702099-rc6s, 21s elapsed] cdp_dw_data_visualization.viq-terraform-example: Still destroying... [id=viz-1734702099-rc6s, 31s elapsed] cdp_dw_data_visualization.viq-terraform-example: Destruction complete after 33s Apply complete! Resources: 0 added, 0 changed, 1 destroyed. ``` --- .golangci.yml | 4 +- docs/resources/dw_data_visualization.md | 74 +++ docs/resources/dw_vw_hive.md | 2 +- docs/resources/dw_vw_impala.md | 1 + .../cdp_dw_data_visualization/resource.tf | 28 + examples/resources/cdp_dw_vw_hive/resource.tf | 2 +- provider/provider.go | 2 + provider/provider_test.go | 2 + resources/dw/dataviz/model_dataviz.go | 37 ++ resources/dw/dataviz/resource_dataviz.go | 360 ++++++++++++ .../dw/dataviz/resource_dataviz_acc_test.go | 114 ++++ resources/dw/dataviz/resource_dataviz_test.go | 520 ++++++++++++++++++ resources/dw/dataviz/schema_dataviz.go | 105 ++++ resources/dw/resource_dw_acc_test.go | 45 +- .../hive/resource_hive_vw_acc_test.go | 6 +- .../resources/dw_data_visualization.md.tmpl | 26 + utils/utils.go | 22 + utils/utils_test.go | 31 ++ 18 files changed, 1367 insertions(+), 14 deletions(-) create mode 100644 docs/resources/dw_data_visualization.md create mode 100644 examples/resources/cdp_dw_data_visualization/resource.tf create mode 100644 resources/dw/dataviz/model_dataviz.go create mode 100644 resources/dw/dataviz/resource_dataviz.go create mode 100644 resources/dw/dataviz/resource_dataviz_acc_test.go create mode 100644 resources/dw/dataviz/resource_dataviz_test.go create mode 100644 resources/dw/dataviz/schema_dataviz.go create mode 100644 templates/resources/dw_data_visualization.md.tmpl diff --git a/.golangci.yml b/.golangci.yml index f80f8ffc..cea55da8 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -26,7 +26,9 @@ linters-settings: skip-generated: true output: - format: colored-line-number + formats: + - format: colored-line-number + path: stdout print-issued-lines: true issues: diff --git a/docs/resources/dw_data_visualization.md b/docs/resources/dw_data_visualization.md new file mode 100644 index 00000000..a64f3506 --- /dev/null +++ b/docs/resources/dw_data_visualization.md @@ -0,0 +1,74 @@ +--- +page_title: "cdp_dw_data_visualization Resource - terraform-provider-cdp" +subcategory: "Data Warehouse" +description: |- + Cloudera Data Warehouse (CDW) integrates Data Visualization https://docs.cloudera.com/data-warehouse/cloud/managing-warehouses/topics/dw-use-data-visualization.html for building graphic representations of data, dashboards, and visual applications based on CDW data. +--- + +# cdp_dw_data_visualization (Resource) + +Cloudera Data Warehouse (CDW) integrates [Data Visualization](https://docs.cloudera.com/data-warehouse/cloud/managing-warehouses/topics/dw-use-data-visualization.html) for building graphic representations of data, dashboards, and visual applications based on CDW data. + +## Example Usage + +```terraform +## Copyright 2025 Cloudera. All Rights Reserved. +# +# This file is licensed under the Apache License Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. +# +# This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS +# OF ANY KIND, either express or implied. Refer to the License for the specific +# permissions and limitations governing your use of the file. + +terraform { + required_providers { + cdp = { + source = "cloudera/cdp" + } + } +} + +resource "cdp_dw_data_visualization" "example" { + cluster_id = "env-id" + name = "data-visualization" + image_version = "7.2.7-b48" + + resource_template = "viz-default" + + user_groups = ["ugrp0", "ugrp1"] + admin_groups = ["admgrp0", "admgrp1"] +} +``` + + +## Schema + +### Required + +- `admin_groups` (List of String) List of the LDAP groups which can administer this Data Visualization instance. At least one valid group is required. +- `cluster_id` (String) The id of the CDW Cluster which the Data Visualization is attached to. +- `name` (String) The name of the Data Visualization. +- `user_groups` (List of String) List of the LDAP groups which have access to this Data Visualization instance. It might be an empty list. + +### Optional + +- `image_version` (String) The version of the Data Visualization. +- `polling_options` (Attributes) Polling related configuration options that could specify various values that will be used during CDP resource creation. (see [below for nested schema](#nestedatt--polling_options)) +- `resource_template` (String) The name of the resource template being used. Available options: viz-default, viz-low, viz-medium, viz-large. Empty means the default resources template will be assigned. + +### Read-Only + +- `id` (String) The ID of this resource. +- `last_updated` (String) Timestamp of the last Terraform update of the order. +- `status` (String) The status of the Data Visualization. + + +### Nested Schema for `polling_options` + +Optional: + +- `async` (Boolean) Boolean value that specifies if Terraform should wait for resource creation/deletion. +- `call_failure_threshold` (Number) Threshold value that specifies how many times should a single call failure happen before giving up the polling. +- `polling_timeout` (Number) Timeout value in minutes that specifies for how long should the polling go for resource creation/deletion. diff --git a/docs/resources/dw_vw_hive.md b/docs/resources/dw_vw_hive.md index 37163c64..a590ade8 100644 --- a/docs/resources/dw_vw_hive.md +++ b/docs/resources/dw_vw_hive.md @@ -42,7 +42,7 @@ resource "cdp_dw_vw_hive" "example" { max_group_count = 3 disable_auto_suspend = false auto_suspend_timeout_seconds = 100 - scale_wait_time_seconds = 230 // either headroom or scale_wait_time_seconds can be configured + scale_wait_time_seconds = 230 // either headroom or scale_wait_time_seconds can be configured headroom = 1 max_concurrent_isolated_queries = 5 max_nodes_per_isolated_query = 2 diff --git a/docs/resources/dw_vw_impala.md b/docs/resources/dw_vw_impala.md index a1ef2db6..5820501e 100644 --- a/docs/resources/dw_vw_impala.md +++ b/docs/resources/dw_vw_impala.md @@ -26,6 +26,7 @@ resource "cdp_dw_vw_impala" "impala-terraform" { cluster_id = var.cluster_id database_catalog_id = var.database_catalog_id name = var.name + image_version = var.image_version } ``` diff --git a/examples/resources/cdp_dw_data_visualization/resource.tf b/examples/resources/cdp_dw_data_visualization/resource.tf new file mode 100644 index 00000000..0878f213 --- /dev/null +++ b/examples/resources/cdp_dw_data_visualization/resource.tf @@ -0,0 +1,28 @@ +## Copyright 2025 Cloudera. All Rights Reserved. +# +# This file is licensed under the Apache License Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. +# +# This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS +# OF ANY KIND, either express or implied. Refer to the License for the specific +# permissions and limitations governing your use of the file. + +terraform { + required_providers { + cdp = { + source = "cloudera/cdp" + } + } +} + +resource "cdp_dw_data_visualization" "example" { + cluster_id = "env-id" + name = "data-visualization" + image_version = "7.2.7-b48" + + resource_template = "viz-default" + + user_groups = ["ugrp0", "ugrp1"] + admin_groups = ["admgrp0", "admgrp1"] +} diff --git a/examples/resources/cdp_dw_vw_hive/resource.tf b/examples/resources/cdp_dw_vw_hive/resource.tf index dad5463a..b83c5c36 100644 --- a/examples/resources/cdp_dw_vw_hive/resource.tf +++ b/examples/resources/cdp_dw_vw_hive/resource.tf @@ -28,7 +28,7 @@ resource "cdp_dw_vw_hive" "example" { max_group_count = 3 disable_auto_suspend = false auto_suspend_timeout_seconds = 100 - scale_wait_time_seconds = 230 // either headroom or scale_wait_time_seconds can be configured + scale_wait_time_seconds = 230 // either headroom or scale_wait_time_seconds can be configured headroom = 1 max_concurrent_isolated_queries = 5 max_nodes_per_isolated_query = 2 diff --git a/provider/provider.go b/provider/provider.go index 4713989d..ba4693bb 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -31,6 +31,7 @@ import ( "github.com/cloudera/terraform-provider-cdp/resources/de" dwaws "github.com/cloudera/terraform-provider-cdp/resources/dw/cluster/aws" dwdatabasecatalog "github.com/cloudera/terraform-provider-cdp/resources/dw/databasecatalog" + "github.com/cloudera/terraform-provider-cdp/resources/dw/dataviz" "github.com/cloudera/terraform-provider-cdp/resources/dw/virtualwarehouse/hive" "github.com/cloudera/terraform-provider-cdp/resources/dw/virtualwarehouse/impala" "github.com/cloudera/terraform-provider-cdp/resources/environments" @@ -252,6 +253,7 @@ func (p *CdpProvider) Resources(_ context.Context) []func() resource.Resource { de.NewServiceResource, hive.NewHiveResource, impala.NewImpalaResource, + dataviz.NewDataVizResource, dwaws.NewDwClusterResource, dwdatabasecatalog.NewDwDatabaseCatalogResource, } diff --git a/provider/provider_test.go b/provider/provider_test.go index 48d4b767..d32516a7 100644 --- a/provider/provider_test.go +++ b/provider/provider_test.go @@ -31,6 +31,7 @@ import ( "github.com/cloudera/terraform-provider-cdp/resources/de" dwaws "github.com/cloudera/terraform-provider-cdp/resources/dw/cluster/aws" dwdatabasecatalog "github.com/cloudera/terraform-provider-cdp/resources/dw/databasecatalog" + "github.com/cloudera/terraform-provider-cdp/resources/dw/dataviz" "github.com/cloudera/terraform-provider-cdp/resources/dw/virtualwarehouse/hive" "github.com/cloudera/terraform-provider-cdp/resources/dw/virtualwarehouse/impala" "github.com/cloudera/terraform-provider-cdp/resources/environments" @@ -638,6 +639,7 @@ func TestCdpProvider_Resources(t *testing.T) { de.NewServiceResource, hive.NewHiveResource, impala.NewImpalaResource, + dataviz.NewDataVizResource, dwaws.NewDwClusterResource, dwdatabasecatalog.NewDwDatabaseCatalogResource, } diff --git a/resources/dw/dataviz/model_dataviz.go b/resources/dw/dataviz/model_dataviz.go new file mode 100644 index 00000000..a2d0bb2d --- /dev/null +++ b/resources/dw/dataviz/model_dataviz.go @@ -0,0 +1,37 @@ +// Copyright 2025 Cloudera. All Rights Reserved. +// +// This file is licensed under the Apache License Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. +// +// This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS +// OF ANY KIND, either express or implied. Refer to the License for the specific +// permissions and limitations governing your use of the file. + +package dataviz + +import ( + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/cloudera/terraform-provider-cdp/utils" +) + +type resourceModel struct { + ID types.String `tfsdk:"id"` + ClusterID types.String `tfsdk:"cluster_id"` + Name types.String `tfsdk:"name"` + + ImageVersion types.String `tfsdk:"image_version"` + ResourceTemplate types.String `tfsdk:"resource_template"` + + UserGroups types.List `tfsdk:"user_groups"` + AdminGroups types.List `tfsdk:"admin_groups"` + + LastUpdated types.String `tfsdk:"last_updated"` + Status types.String `tfsdk:"status"` + PollingOptions *utils.PollingOptions `tfsdk:"polling_options"` +} + +func (m *resourceModel) GetPollingOptions() *utils.PollingOptions { + return m.PollingOptions +} diff --git a/resources/dw/dataviz/resource_dataviz.go b/resources/dw/dataviz/resource_dataviz.go new file mode 100644 index 00000000..abb1e7c6 --- /dev/null +++ b/resources/dw/dataviz/resource_dataviz.go @@ -0,0 +1,360 @@ +// Copyright 2025 Cloudera. All Rights Reserved. +// +// This file is licensed under the Apache License Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. +// +// This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS +// OF ANY KIND, either express or implied. Refer to the License for the specific +// permissions and limitations governing your use of the file. + +package dataviz + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" + + "github.com/cloudera/terraform-provider-cdp/cdp-sdk-go/cdp" + "github.com/cloudera/terraform-provider-cdp/cdp-sdk-go/gen/dw/client/operations" + "github.com/cloudera/terraform-provider-cdp/cdp-sdk-go/gen/dw/models" + "github.com/cloudera/terraform-provider-cdp/utils" +) + +type datavizResource struct { + client *cdp.Client +} + +var ( + _ resource.Resource = (*datavizResource)(nil) + _ resource.ResourceWithConfigure = (*datavizResource)(nil) + _ resource.ResourceWithImportState = (*datavizResource)(nil) + _ resource.ResourceWithValidateConfig = (*datavizResource)(nil) +) + +const ( + resourceTemplateDefault string = "viz-default" + resourceTemplateLow string = "viz-low" + resourceTemplateMedium string = "viz-medium" + resourceTemplateLarge string = "viz-large" +) + +func NewDataVizResource() resource.Resource { + return &datavizResource{} +} + +func (r *datavizResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.client = utils.GetCdpClientForResource(req, resp) +} + +func (r *datavizResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) { + var data resourceModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + if admGrp := data.AdminGroups; len(admGrp.Elements()) == 0 { + resp.Diagnostics.AddAttributeError( + path.Root("admin_groups"), + "Invalid administrator groups", + "The admin_groups must have at least one group.", + ) + } + + // If resource template not defined then default will be applied -> viz-default, otherwise we validate + if !data.ResourceTemplate.IsNull() && data.ResourceTemplate.ValueString() != "" { + // oly allow certain values for the resource template field + resourceTemplatePossibleValues := []string{ + resourceTemplateDefault, + resourceTemplateLow, + resourceTemplateMedium, + resourceTemplateLarge, + } + if !isAnyOf(data.ResourceTemplate.ValueString(), resourceTemplatePossibleValues...) { + resp.Diagnostics.AddAttributeError( + path.Root("resource_template"), + "Invalid resource template", + fmt.Sprintf("The resource_template can be one of the following if defined: %s.", strings.Join(resourceTemplatePossibleValues, ", ")), + ) + } + } +} + +func (r *datavizResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} + +func (r *datavizResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_dw_data_visualization" +} + +func (r *datavizResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = datavizSchema +} + +func (r *datavizResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + // Retrieve values from plan + var plan resourceModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Create new Data Visualization + create, err := r.createDataViz(createRequestFromPlan(ctx, plan)) + if err != nil { + resp.Diagnostics.AddError( + "Error creating Data Visualization", + fmt.Sprintf("Could not create Data Visualization, unexpected error: %v", err), + ) + return + } + + clusterID := plan.ClusterID.ValueStringPointer() + vizID := &create.GetPayload().DataVisualizationID + + // Wait the desired state + if opts := plan.PollingOptions; opts == nil || !opts.Async.ValueBool() { + if _, err = r.retryStateConf(ctx, setupRetryCfg(clusterID, vizID), &plan).WaitForStateContext(ctx); err != nil { + resp.Diagnostics.AddError( + "Error waiting for Data Visualization", + "Could not create Data Visualization, unexpected error: "+err.Error(), + ) + return + } + } + + // Describe the fresh data + describe, err := r.describeDataViz(describeRequest(ctx, clusterID, vizID)) + if err != nil { + resp.Diagnostics.AddError( + "Error creating Data Visualization", + fmt.Sprintf("Could not describe Data Visualization, unexpected error: %v", err), + ) + return + } + + diags = resp.State.Set( + ctx, + stateFromDataViz( + *clusterID, + describe.GetPayload().DataVisualization, + plan.ResourceTemplate.ValueStringPointer(), + time.Now(), + plan.PollingOptions, + ), + ) + resp.Diagnostics.Append(diags...) +} + +func (r *datavizResource) Read(ctx context.Context, _ resource.ReadRequest, _ *resource.ReadResponse) { + tflog.Warn(ctx, "Read operation is not implemented yet.") +} + +func (r *datavizResource) Update(ctx context.Context, _ resource.UpdateRequest, _ *resource.UpdateResponse) { + tflog.Warn(ctx, "Update operation is not implemented yet.") +} + +func (r *datavizResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state resourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + clusterID := state.ClusterID.ValueStringPointer() + vizID := state.ID.ValueStringPointer() + + if _, err := r.deleteDataViz(deleteRequest(ctx, clusterID, vizID)); err != nil { + if strings.Contains(err.Error(), "unable to get viz-webapp") { + return + } + resp.Diagnostics.AddError( + "Error deleting Data Visualization", + fmt.Sprintf("Could not delete Data Visualization, unexpected error: %v", err), + ) + return + } + + if opts := state.PollingOptions; opts == nil || !opts.Async.ValueBool() { + if _, err := r.retryStateConf(ctx, teardownRetryCfg(clusterID, vizID), &state).WaitForStateContext(ctx); err != nil { + resp.Diagnostics.AddError( + "Error waiting for Data Visualization to delete", + fmt.Sprintf("Could not delete Data Visualization, unexpected error: %v", err), + ) + return + } + } +} + +func (r *datavizResource) createDataViz(p *operations.CreateDataVisualizationParams) (*operations.CreateDataVisualizationOK, error) { + return r.client.Dw.Operations.CreateDataVisualization(p) +} + +func (r *datavizResource) describeDataViz(p *operations.DescribeDataVisualizationParams) (*operations.DescribeDataVisualizationOK, error) { + return r.client.Dw.Operations.DescribeDataVisualization(p) +} + +func (r *datavizResource) deleteDataViz(p *operations.DeleteDataVisualizationParams) (*operations.DeleteDataVisualizationOK, error) { + return r.client.Dw.Operations.DeleteDataVisualization(p) +} + +func createRequestFromPlan(ctx context.Context, plan resourceModel) *operations.CreateDataVisualizationParams { + return operations.NewCreateDataVisualizationParamsWithContext(ctx). + WithInput(&models.CreateDataVisualizationRequest{ + ClusterID: plan.ClusterID.ValueStringPointer(), + Name: plan.Name.ValueStringPointer(), + + ImageVersion: plan.ImageVersion.ValueString(), + Config: &models.VizConfig{ + AdminGroups: utils.FromListValueToStringList(plan.AdminGroups), + UserGroups: utils.FromListValueToStringList(plan.UserGroups), + }, + + ResourceTemplate: plan.ResourceTemplate.ValueString(), + }) +} + +func describeRequest(ctx context.Context, clusterID *string, vizID *string) *operations.DescribeDataVisualizationParams { + return operations.NewDescribeDataVisualizationParamsWithContext(ctx). + WithInput(&models.DescribeDataVisualizationRequest{ + ClusterID: clusterID, + DataVisualizationID: vizID, + }) +} + +func deleteRequest(ctx context.Context, clusterID *string, vizID *string) *operations.DeleteDataVisualizationParams { + return operations.NewDeleteDataVisualizationParamsWithContext(ctx). + WithInput(&models.DeleteDataVisualizationRequest{ + ClusterID: clusterID, + DataVisualizationID: vizID, + }) +} + +func (r *datavizResource) retryStateConf( + ctx context.Context, + cfg *retryStateCfg, + po utils.HasPollingOptions, +) *retry.StateChangeConf { + failedCnt := 0 + return &retry.StateChangeConf{ + Pending: cfg.pending, + Target: cfg.target, + Delay: 30 * time.Second, + Timeout: utils.GetPollingTimeout(po, 20*time.Minute), + PollInterval: 10 * time.Second, + Refresh: r.stateRefresh(ctx, cfg.clusterID, cfg.vizID, &failedCnt, utils.GetCallFailureThreshold(po, 3)), + } +} + +func (r *datavizResource) stateRefresh(ctx context.Context, clusterID *string, vizID *string, failedCnt *int, failureThreshold int) func() (any, string, error) { + return func() (any, string, error) { + tflog.Debug(ctx, "Describing Data Visualisation") + + resp, err := r.describeDataViz(describeRequest(ctx, clusterID, vizID)) + if err != nil { + tflog.Error(ctx, fmt.Sprintf("Error describing Data Visualisation, error, %v", err)) + + if strings.Contains(err.Error(), "unable to get viz-webapp") { + // the &models.DescribeDataVisualizationResponse{} has to be a response otherwise it end up in an infinite loop + return &models.DescribeDataVisualizationResponse{}, "Deleted", nil + } + + // the "Data Visualization not found" will be the correct way of handling + if strings.Contains(err.Error(), "Data Visualization not found") { + // the &models.DescribeDataVisualizationResponse{} has to be a response otherwise it end up in an infinite loop + return &models.DescribeDataVisualizationResponse{}, "Deleted", nil + } + + *failedCnt++ + if *failedCnt <= failureThreshold { + tflog.Warn(ctx, fmt.Sprintf("could not describe Data Visualization "+ + "due to [%s] but threshold limit is not reached yet (%d out of %d).", err.Error(), failedCnt, failureThreshold)) + return nil, "", nil + } + tflog.Error(ctx, fmt.Sprintf("error describing Data Visualization due to [%s] "+ + "failure threshold limit exceeded.", err.Error())) + return nil, "", err + } + + *failedCnt = 0 + dataViz := resp.GetPayload().DataVisualization + tflog.Debug(ctx, fmt.Sprintf("Described Data Visualization %s with status %s", dataViz.ID, dataViz.Status)) + return dataViz, dataViz.Status, nil + } +} + +func stateFromDataViz( + clusterID string, + viz *models.DataVisualizationSummary, + template *string, + updated time.Time, + pollingOpts *utils.PollingOptions, +) resourceModel { + t := types.StringNull() + if template != nil { + t = types.StringValue(*template) + } + + return resourceModel{ + ID: types.StringValue(viz.ID), + ClusterID: types.StringValue(clusterID), + Name: types.StringValue(viz.Name), + + ImageVersion: types.StringValue(viz.ImageVersion), + ResourceTemplate: t, + + UserGroups: utils.FromStringListToListValue(viz.UserGroups), + AdminGroups: utils.FromStringListToListValue(viz.AdminGroups), + + LastUpdated: types.StringValue(updated.Format(time.RFC850)), + Status: types.StringValue(viz.Status), + + PollingOptions: pollingOpts, + } +} + +type retryStateCfg struct { + clusterID *string + vizID *string + pending []string + target []string +} + +func setupRetryCfg(clusterID *string, vizID *string) *retryStateCfg { + return &retryStateCfg{ + clusterID: clusterID, + vizID: vizID, + pending: []string{"Accepted", "Creating", "Created", "Starting"}, + target: []string{"Running"}, + } +} + +func teardownRetryCfg(clusterID *string, vizID *string) *retryStateCfg { + return &retryStateCfg{ + clusterID: clusterID, + vizID: vizID, + pending: []string{"Deleting", "Running", "Stopping", "Stopped", "Creating", "Created", "Starting", "Updating"}, + target: []string{"Deleted"}, + } +} + +func isAnyOf(v string, possibleValues ...string) bool { + for _, pv := range possibleValues { + if v == pv { + return true + } + } + return false +} diff --git a/resources/dw/dataviz/resource_dataviz_acc_test.go b/resources/dw/dataviz/resource_dataviz_acc_test.go new file mode 100644 index 00000000..323fdc6d --- /dev/null +++ b/resources/dw/dataviz/resource_dataviz_acc_test.go @@ -0,0 +1,114 @@ +// Copyright 2025 Cloudera. All Rights Reserved. +// +// This file is licensed under the Apache License Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. +// +// This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS +// OF ANY KIND, either express or implied. Refer to the License for the specific +// permissions and limitations governing your use of the file. + +//go:build dataviz + +package dataviz_test + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + + "github.com/cloudera/terraform-provider-cdp/cdp-sdk-go/gen/dw/client/operations" + "github.com/cloudera/terraform-provider-cdp/cdp-sdk-go/gen/dw/models" + "github.com/cloudera/terraform-provider-cdp/cdpacctest" + "github.com/cloudera/terraform-provider-cdp/utils" +) + +type datavizTestParameters struct { + Name string + ClusterID string +} + +func dataVisualizationTestPreCheck(t *testing.T) { + errMsg := "AWS CDW Data Visualization Terraform acceptance testing requires environment variable %s to be set" + if _, ok := os.LookupEnv("CDW_CLUSTER_ID"); !ok { + t.Skipf(errMsg, "CDW_CLUSTER_ID") + } +} + +func TestAccDataViz_Low(t *testing.T) { + params := datavizTestParameters{ + Name: cdpacctest.RandomShortWithPrefix(cdpacctest.ResourcePrefix), + ClusterID: os.Getenv("CDW_CLUSTER_ID"), + } + + const testResource = "cdp_dw_data_visualization.test-dataviz-low" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + cdpacctest.PreCheck(t) + dataVisualizationTestPreCheck(t) + }, + ProtoV6ProviderFactories: cdpacctest.TestAccProtoV6ProviderFactories, + CheckDestroy: testCheckDataVizDestroy, + Steps: []resource.TestStep{ + // Create and Read testing + { + Config: utils.Concat( + cdpacctest.TestAccCdpProviderConfig(), + testAccDtaVizLowConfig(params)), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(testResource, "name", params.Name), + resource.TestCheckResourceAttr(testResource, "cluster_id", params.ClusterID), + resource.TestCheckResourceAttrSet(testResource, "image_version"), + resource.TestCheckResourceAttr(testResource, "admin_groups.0", "dwx-dummy-ldap-group"), + ), + }, + // Delete testing automatically occurs in TestCase + }, + }) +} + +func testAccDtaVizLowConfig(p datavizTestParameters) string { + // NOTE: the LDAP groups vary by CDP environment, the admin_groups has to be injected via an ENV var or some other way, we use now a dummy one + return fmt.Sprintf(` + resource "cdp_dw_data_visualization" "test-dataviz-low" { + cluster_id = %[1]q + name = %[2]q + + resource_template = "viz-low" + + admin_groups = ["dwx-dummy-ldap-group"] + user_groups = [] + } + `, p.ClusterID, p.Name) +} + +func testCheckDataVizDestroy(s *terraform.State) error { + for _, rs := range s.RootModule().Resources { + if rs.Type != "cdp_dw_data_visualization" { + continue + } + + cdpClient := cdpacctest.GetCdpClientForAccTest() + clusterID := rs.Primary.Attributes["cluster_id"] + _, err := cdpClient.Dw.Operations. + DescribeDataVisualization( + operations.NewDescribeDataVisualizationParamsWithContext(context.Background()). + WithInput(&models.DescribeDataVisualizationRequest{ + ClusterID: &clusterID, + DataVisualizationID: &rs.Primary.ID, + })) + if err != nil { + if strings.Contains(err.Error(), "unable to get viz-webapp") { + continue + } + return err + } + } + return nil +} diff --git a/resources/dw/dataviz/resource_dataviz_test.go b/resources/dw/dataviz/resource_dataviz_test.go new file mode 100644 index 00000000..29882623 --- /dev/null +++ b/resources/dw/dataviz/resource_dataviz_test.go @@ -0,0 +1,520 @@ +// Copyright 2025 Cloudera. All Rights Reserved. +// +// This file is licensed under the Apache License Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. +// +// This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS +// OF ANY KIND, either express or implied. Refer to the License for the specific +// permissions and limitations governing your use of the file. + +package dataviz + +import ( + "context" + "fmt" + "testing" + + "github.com/go-openapi/runtime" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + + "github.com/cloudera/terraform-provider-cdp/cdp-sdk-go/cdp" + dwclient "github.com/cloudera/terraform-provider-cdp/cdp-sdk-go/gen/dw/client" + "github.com/cloudera/terraform-provider-cdp/cdp-sdk-go/gen/dw/client/operations" + "github.com/cloudera/terraform-provider-cdp/cdp-sdk-go/gen/dw/models" + mocks "github.com/cloudera/terraform-provider-cdp/mocks/github.com/cloudera/terraform-provider-cdp/cdp-sdk-go/gen/dw/client/operations" +) + +var testDatavizSchema = schema.Schema{ + MarkdownDescription: "Cloudera Data Warehouse (CDW) integrates [Data Visualization](https://docs.cloudera.com/data-warehouse/cloud/managing-warehouses/topics/dw-use-data-visualization.html) for building graphic representations of data, dashboards, and visual applications based on CDW data.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "cluster_id": schema.StringAttribute{ + Required: true, + MarkdownDescription: "The id of the CDW Cluster which the Data Visualization is attached to.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "name": schema.StringAttribute{ + Required: true, + MarkdownDescription: "The name of the Data Visualization.", + }, + + "image_version": schema.StringAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "The version of the Data Visualization.", + }, + "resource_template": schema.StringAttribute{ + Optional: true, + Computed: true, + // TODO vcsomor add validation logic to the allowed types + MarkdownDescription: "The name of the resource template being used. Available options: viz-default, viz-low, viz-medium, viz-large. Empty means the default resources template will be assigned.", + }, + + "user_groups": schema.ListAttribute{ + Required: true, + ElementType: types.StringType, + MarkdownDescription: "List of the LDAP groups which have access to this Data Visualization instance. It might be an empty list.", + }, + "admin_groups": schema.ListAttribute{ + Required: true, + ElementType: types.StringType, + MarkdownDescription: "List of the LDAP groups which can administer this Data Visualization instance. At least one valid group is required.", + }, + + // TODO vcsomor add missing Tags to the API + + "last_updated": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Timestamp of the last Terraform update of the order.", + }, + "status": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The status of the Data Visualization.", + }, + "polling_options": schema.SingleNestedAttribute{ + MarkdownDescription: "Polling related configuration options that could specify various values that will be used during CDP resource creation.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "async": schema.BoolAttribute{ + MarkdownDescription: "Boolean value that specifies if Terraform should wait for resource creation/deletion.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "polling_timeout": schema.Int64Attribute{ + MarkdownDescription: "Timeout value in minutes that specifies for how long should the polling go for resource creation/deletion.", + Default: int64default.StaticInt64(20), + Computed: true, + Optional: true, + }, + "call_failure_threshold": schema.Int64Attribute{ + MarkdownDescription: "Threshold value that specifies how many times should a single call failure happen before giving up the polling.", + Default: int64default.StaticInt64(3), + Computed: true, + Optional: true, + }, + }, + }, + }, +} + +type MockTransport struct { + runtime.ClientTransport +} + +func newDwApi(client *mocks.MockDwClientService) *datavizResource { + return &datavizResource{ + client: &cdp.Client{ + Dw: &dwclient.Dw{ + Operations: client, + Transport: MockTransport{}, + }}} +} + +func createRawDatavizResource(overrides map[string]tftypes.Value) tftypes.Value { + value := map[string]tftypes.Value{ + "id": tftypes.NewValue(tftypes.String, ""), + "cluster_id": tftypes.NewValue(tftypes.String, "cluster-id"), + "name": tftypes.NewValue(tftypes.String, "test-name"), + + "image_version": tftypes.NewValue(tftypes.String, "test-version"), + "resource_template": tftypes.NewValue(tftypes.String, "viz-default"), + + "user_groups": tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, + []tftypes.Value{ + tftypes.NewValue(tftypes.String, "user-group1"), + tftypes.NewValue(tftypes.String, "user-group2"), + }), + "admin_groups": tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, + []tftypes.Value{ + tftypes.NewValue(tftypes.String, "admin-group1"), + tftypes.NewValue(tftypes.String, "admin-group2"), + }), + "last_updated": tftypes.NewValue(tftypes.String, ""), + "status": tftypes.NewValue(tftypes.String, "Running"), + + "polling_options": tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "async": tftypes.Bool, + "polling_timeout": tftypes.Number, + "call_failure_threshold": tftypes.Number, + }}, map[string]tftypes.Value{ + "async": tftypes.NewValue(tftypes.Bool, true), + "polling_timeout": tftypes.NewValue(tftypes.Number, 90), + "call_failure_threshold": tftypes.NewValue(tftypes.Number, 3), + }), + } + + for k, v := range overrides { + value[k] = v + } + + return tftypes.NewValue( + // schema -------------------------- + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "id": tftypes.String, + "cluster_id": tftypes.String, + "name": tftypes.String, + + "image_version": tftypes.String, + "resource_template": tftypes.String, + + "user_groups": tftypes.List{ElementType: tftypes.String}, + "admin_groups": tftypes.List{ElementType: tftypes.String}, + + "last_updated": tftypes.String, + "status": tftypes.String, + "polling_options": tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "async": tftypes.Bool, + "polling_timeout": tftypes.Number, + "call_failure_threshold": tftypes.Number, + }, + }, + }, + }, value) +} + +type DataVizTestSuite struct { + suite.Suite +} + +func TestDataViz(t *testing.T) { + suite.Run(t, new(DataVizTestSuite)) +} + +func (suite *DataVizTestSuite) SetupTest() { +} + +func (suite *DataVizTestSuite) TestDataVizMetadata() { + dwApi := newDwApi(new(mocks.MockDwClientService)) + resp := resource.MetadataResponse{} + + // Function under test + dwApi.Metadata( + context.TODO(), + resource.MetadataRequest{ProviderTypeName: "cdp"}, + &resp, + ) + suite.Equal("cdp_dw_data_visualization", resp.TypeName) +} + +func (suite *DataVizTestSuite) TestDatavizSchema() { + dwApi := newDwApi(new(mocks.MockDwClientService)) + resp := resource.SchemaResponse{} + + // Function under test + dwApi.Schema( + context.TODO(), + resource.SchemaRequest{}, + &resp, + ) + suite.Equal(testDatavizSchema, resp.Schema) +} + +func (suite *DataVizTestSuite) TestDatavizCreate_Success() { + ctx := context.TODO() + + client := new(mocks.MockDwClientService) + client.On("CreateDataVisualization", mock.Anything). + Return(&operations.CreateDataVisualizationOK{ + Payload: &models.CreateDataVisualizationResponse{ + DataVisualizationID: "test-id", + }}, nil) + client.On("DescribeDataVisualization", mock.Anything). + Return(&operations.DescribeDataVisualizationOK{ + Payload: &models.DescribeDataVisualizationResponse{ + DataVisualization: &models.DataVisualizationSummary{ + ID: "test-id", + Name: "test-name", + ImageVersion: "test-version", + AdminGroups: []string{"admin-group1", "admin-group2"}, + UserGroups: []string{"user-group1", "user-group2"}, + }}}, nil) + dwApi := newDwApi(client) + + resp := &resource.CreateResponse{ + State: tfsdk.State{ + Schema: testDatavizSchema, + }, + } + + // Function under test + dwApi.Create( + ctx, + resource.CreateRequest{ + Plan: tfsdk.Plan{ + Raw: createRawDatavizResource(nil), + Schema: testDatavizSchema, + }, + }, + resp, + ) + var result resourceModel + resp.State.Get(ctx, &result) + suite.False(resp.Diagnostics.HasError()) + suite.Equal("test-id", result.ID.ValueString()) + suite.Equal("cluster-id", result.ClusterID.ValueString()) + suite.Equal("test-name", result.Name.ValueString()) +} + +func (suite *DataVizTestSuite) TestDatavizCreate_CreationError() { + ctx := context.TODO() + client := new(mocks.MockDwClientService) + client.On("CreateDataVisualization", mock.Anything). + Return(nil, fmt.Errorf("create failed")) + client.On("DescribeDataVisualization", mock.Anything). + Return(&operations.DescribeDataVisualizationOK{}, nil) + + dwApi := newDwApi(client) + + resp := &resource.CreateResponse{ + State: tfsdk.State{ + Schema: testDatavizSchema, + }, + } + + // Function under test + dwApi.Create(ctx, resource.CreateRequest{ + Plan: tfsdk.Plan{ + Raw: createRawDatavizResource(nil), + Schema: testDatavizSchema, + }, + }, resp) + var result resourceModel + resp.State.Get(ctx, &result) + suite.True(resp.Diagnostics.HasError()) + + err0 := resp.Diagnostics.Errors()[0] + suite.Contains(err0.Summary(), "Error creating Data Visualization") + suite.Contains(err0.Detail(), "Could not create Data Visualization, unexpected error: create failed") +} + +func (suite *DataVizTestSuite) TestDatavizCreate_DescribeError() { + ctx := context.TODO() + client := new(mocks.MockDwClientService) + client.On("CreateDataVisualization", mock.Anything). + Return(&operations.CreateDataVisualizationOK{ + Payload: &models.CreateDataVisualizationResponse{ + DataVisualizationID: "test-id", + }}, nil) + client.On("DescribeDataVisualization", mock.Anything). + Return(nil, fmt.Errorf("describe failed")) + dwApi := newDwApi(client) + + resp := &resource.CreateResponse{ + State: tfsdk.State{ + Schema: testDatavizSchema, + }, + } + + // Function under test + dwApi.Create(ctx, resource.CreateRequest{ + Plan: tfsdk.Plan{ + Raw: createRawDatavizResource(nil), + Schema: testDatavizSchema, + }, + }, resp) + var result resourceModel + resp.State.Get(ctx, &result) + suite.True(resp.Diagnostics.HasError()) + err0 := resp.Diagnostics.Errors()[0] + suite.Contains(err0.Summary(), "Error creating Data Visualization") + suite.Contains(err0.Detail(), "Could not describe Data Visualization, unexpected error: describe failed") +} + +func (suite *DataVizTestSuite) TestDatavizDeletion_Success() { + client := new(mocks.MockDwClientService) + client.On("DeleteDataVisualization", mock.Anything). + Return(&operations.DeleteDataVisualizationOK{}, nil) + dwApi := newDwApi(client) + + resp := &resource.DeleteResponse{} + + // Function under test + dwApi.Delete(context.TODO(), resource.DeleteRequest{ + State: tfsdk.State{ + Schema: testDatavizSchema, + Raw: createRawDatavizResource(nil), + }, + }, resp) + suite.False(resp.Diagnostics.HasError()) +} + +func (suite *DataVizTestSuite) TestDatavizDeletion_ReturnsError() { + client := new(mocks.MockDwClientService) + client.On("DeleteDataVisualization", mock.Anything). + Return(nil, fmt.Errorf("delete failed")) + dwApi := newDwApi(client) + + resp := &resource.DeleteResponse{} + + // Function under test + dwApi.Delete(context.TODO(), resource.DeleteRequest{ + State: tfsdk.State{ + Schema: testDatavizSchema, + Raw: createRawDatavizResource(nil), + }, + }, resp) + suite.True(resp.Diagnostics.HasError()) +} + +func (suite *DataVizTestSuite) TestStateRefresh_Success() { + client := new(mocks.MockDwClientService) + client.On("DescribeDataVisualization", mock.Anything).Return( + &operations.DescribeDataVisualizationOK{ + Payload: &models.DescribeDataVisualizationResponse{ + DataVisualization: &models.DataVisualizationSummary{ + ID: "dataviz-id", + Status: "Running", + }, + }, + }, + nil) + dwApi := newDwApi(client) + + callFailedCount := 0 + callFailureThreshold := 3 + + // Function under test + refresh := dwApi.stateRefresh( + context.TODO(), + ptrOf("cluster-id"), + ptrOf("dataviz-id"), + &callFailedCount, + callFailureThreshold, + ) + _, status, err := refresh() + suite.NoError(err) + suite.Equal("Running", status) +} + +func (suite *DataVizTestSuite) TestStateRefresh_FailureThresholdReached() { + client := new(mocks.MockDwClientService) + client.On("DescribeDataVisualization", mock.Anything).Return( + nil, fmt.Errorf("unknown error")) + dwApi := newDwApi(client) + + callFailedCount := 0 + callFailureThreshold := 3 + + // Function under test + refresh := dwApi.stateRefresh( + context.TODO(), + ptrOf("cluster-id"), + ptrOf("dataviz-id"), + &callFailedCount, + callFailureThreshold, + ) + var err error + for i := 0; i <= callFailureThreshold; i++ { + _, _, err = refresh() + } + suite.Error(err, "unknown error") +} + +func (suite *DataVizTestSuite) TestDatavizValidateConfig_Success() { + dwApi := newDwApi(new(mocks.MockDwClientService)) + resp := &resource.ValidateConfigResponse{ + Diagnostics: make(diag.Diagnostics, 0), + } + + // Function under test + dwApi.ValidateConfig( + context.TODO(), + resource.ValidateConfigRequest{ + Config: tfsdk.Config{ + Raw: createRawDatavizResource(nil), + Schema: testDatavizSchema, + }, + }, + resp, + ) + suite.False(resp.Diagnostics.HasError()) +} + +func (suite *DataVizTestSuite) TestDatavizValidateConfig_ValidationErrors() { + dwApi := newDwApi(new(mocks.MockDwClientService)) + resp := &resource.ValidateConfigResponse{ + Diagnostics: make(diag.Diagnostics, 0), + } + + // Function under test + dwApi.ValidateConfig( + context.TODO(), + resource.ValidateConfigRequest{ + Config: tfsdk.Config{ + Raw: createRawDatavizResource( + map[string]tftypes.Value{ + "resource_template": tftypes.NewValue(tftypes.String, "something-invalid"), + "admin_groups": tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, []tftypes.Value{}), + }), + Schema: testDatavizSchema, + }, + }, + resp, + ) + suite.True(resp.Diagnostics.HasError()) + + errAdminGroups := resp.Diagnostics[0] + suite.Equal(errAdminGroups.Summary(), "Invalid administrator groups") + suite.Equal(errAdminGroups.Detail(), "The admin_groups must have at least one group.") + + errResourceTemplate := resp.Diagnostics[1] + suite.Equal(errResourceTemplate.Summary(), "Invalid resource template") + suite.Equal(errResourceTemplate.Detail(), "The resource_template can be one of the following if defined: viz-default, viz-low, viz-medium, viz-large.") +} + +func TestRetryConfigs(t *testing.T) { + assert.Equal( + t, + &retryStateCfg{ + clusterID: ptrOf("cluster-id"), + vizID: ptrOf("dataviz-id"), + pending: []string{"Accepted", "Creating", "Created", "Starting"}, + target: []string{"Running"}, + }, + setupRetryCfg(ptrOf("cluster-id"), ptrOf("dataviz-id")), + ) + + assert.Equal( + t, + &retryStateCfg{ + clusterID: ptrOf("cluster-id"), + vizID: ptrOf("dataviz-id"), + pending: []string{"Deleting", "Running", "Stopping", "Stopped", "Creating", "Created", "Starting", "Updating"}, + target: []string{"Deleted"}, + }, + teardownRetryCfg(ptrOf("cluster-id"), ptrOf("dataviz-id")), + ) +} + +func ptrOf[T any](v T) *T { + return &v +} diff --git a/resources/dw/dataviz/schema_dataviz.go b/resources/dw/dataviz/schema_dataviz.go new file mode 100644 index 00000000..f6b6f64d --- /dev/null +++ b/resources/dw/dataviz/schema_dataviz.go @@ -0,0 +1,105 @@ +// Copyright 2025 Cloudera. All Rights Reserved. +// +// This file is licensed under the Apache License Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. +// +// This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS +// OF ANY KIND, either express or implied. Refer to the License for the specific +// permissions and limitations governing your use of the file. + +package dataviz + +import ( + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default" + "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" +) + +var datavizSchema = schema.Schema{ + MarkdownDescription: "Cloudera Data Warehouse (CDW) integrates [Data Visualization](https://docs.cloudera.com/data-warehouse/cloud/managing-warehouses/topics/dw-use-data-visualization.html) for building graphic representations of data, dashboards, and visual applications based on CDW data.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "cluster_id": schema.StringAttribute{ + Required: true, + MarkdownDescription: "The id of the CDW Cluster which the Data Visualization is attached to.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "name": schema.StringAttribute{ + Required: true, + MarkdownDescription: "The name of the Data Visualization.", + }, + + "image_version": schema.StringAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "The version of the Data Visualization.", + }, + "resource_template": schema.StringAttribute{ + Optional: true, + Computed: true, + // TODO vcsomor add validation logic to the allowed types + MarkdownDescription: "The name of the resource template being used. Available options: viz-default, viz-low, viz-medium, viz-large. Empty means the default resources template will be assigned.", + }, + + "user_groups": schema.ListAttribute{ + Required: true, + ElementType: types.StringType, + MarkdownDescription: "List of the LDAP groups which have access to this Data Visualization instance. It might be an empty list.", + }, + "admin_groups": schema.ListAttribute{ + Required: true, + ElementType: types.StringType, + MarkdownDescription: "List of the LDAP groups which can administer this Data Visualization instance. At least one valid group is required.", + }, + + // TODO vcsomor add missing Tags to the API + + "last_updated": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Timestamp of the last Terraform update of the order.", + }, + "status": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The status of the Data Visualization.", + }, + "polling_options": schema.SingleNestedAttribute{ + MarkdownDescription: "Polling related configuration options that could specify various values that will be used during CDP resource creation.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "async": schema.BoolAttribute{ + MarkdownDescription: "Boolean value that specifies if Terraform should wait for resource creation/deletion.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "polling_timeout": schema.Int64Attribute{ + MarkdownDescription: "Timeout value in minutes that specifies for how long should the polling go for resource creation/deletion.", + Default: int64default.StaticInt64(20), + Computed: true, + Optional: true, + }, + "call_failure_threshold": schema.Int64Attribute{ + MarkdownDescription: "Threshold value that specifies how many times should a single call failure happen before giving up the polling.", + Default: int64default.StaticInt64(3), + Computed: true, + Optional: true, + }, + }, + }, + }, +} diff --git a/resources/dw/resource_dw_acc_test.go b/resources/dw/resource_dw_acc_test.go index 57c9ec3f..5bcd42d1 100644 --- a/resources/dw/resource_dw_acc_test.go +++ b/resources/dw/resource_dw_acc_test.go @@ -1,4 +1,4 @@ -// Copyright 2024 Cloudera. All Rights Reserved. +// Copyright 2025 Cloudera. All Rights Reserved. // // This file is licensed under the Apache License Version 2.0 (the "License"). // You may not use this file except in compliance with the License. @@ -147,6 +147,13 @@ func TestAccDwCluster_Basic(t *testing.T) { StorageLocationBase: os.Getenv(AwsStorageLocationBase), Runtime: os.Getenv(AwsRuntime), } + + const ( + awsClusterResource = "cdp_dw_aws_cluster.test_data_warehouse_aws" + hiveResource = "cdp_dw_vw_hive.test_hive" + dataVisualizationResource = "cdp_dw_data_visualization.test_dataviz" + ) + resource.Test(t, resource.TestCase{ PreCheck: func() { cdpacctest.PreCheck(t) @@ -170,14 +177,21 @@ func TestAccDwCluster_Basic(t *testing.T) { testAccAwsClusterBasicConfig(&envParams), testAccDwCatalog(), testAccHiveVirtualWarehouse(cdpacctest.RandomShortWithPrefix("tf-hive")), - testAccImpalaVirtualWarehouse(cdpacctest.RandomShortWithPrefix("tf-impala"))), + testAccImpalaVirtualWarehouse(cdpacctest.RandomShortWithPrefix("tf-impala")), + testAccDataVisualization(cdpacctest.RandomShortWithPrefix("tf-dataviz"))), Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttr("cdp_dw_aws_cluster.test_data_warehouse_aws", "name", envParams.Name), - resource.TestCheckResourceAttr("cdp_dw_aws_cluster.test_data_warehouse_aws", "status", "Accepted"), - resource.TestCheckResourceAttrSet("cdp_dw_vw_hive.test_hive", "compactor"), - resource.TestCheckResourceAttrSet("cdp_dw_vw_hive.test_hive", "jdbc_url"), - resource.TestCheckResourceAttrSet("cdp_dw_vw_hive.test_hive", "hue_url"), - resource.TestCheckResourceAttrSet("cdp_dw_vw_hive.test_hive", "jwt_token_gen_url"), + resource.TestCheckResourceAttr(awsClusterResource, "name", envParams.Name), + resource.TestCheckResourceAttr(awsClusterResource, "status", "Accepted"), + + resource.TestCheckResourceAttrSet(hiveResource, "compactor"), + resource.TestCheckResourceAttrSet(hiveResource, "jdbc_url"), + resource.TestCheckResourceAttrSet(hiveResource, "hue_url"), + resource.TestCheckResourceAttrSet(hiveResource, "jwt_token_gen_url"), + + // TODO vcsomor add checks for Impala! + + resource.TestCheckResourceAttrSet(dataVisualizationResource, "image_version"), + resource.TestCheckResourceAttr(dataVisualizationResource, "admin_groups.0", "dwx-dummy-ldap-group"), ), }, // Delete testing automatically occurs in TestCase @@ -345,3 +359,18 @@ func testAccImpalaVirtualWarehouse(name string) string { } `, name) } + +func testAccDataVisualization(name string) string { + // NOTE: the LDAP groups vary by CDP environment, the admin_groups has to be injected via an ENV var or some other way, we use now a dummy one + return fmt.Sprintf(` + resource "cdp_dw_data_visualization" "test_dataviz" { + cluster_id = cdp_dw_aws_cluster.test_data_warehouse_aws.cluster_id + name = %[1]q + + resource_template = "viz-low" + + admin_groups = ["dwx-dummy-ldap-group"] + user_groups = [] + } + `, name) +} diff --git a/resources/dw/virtualwarehouse/hive/resource_hive_vw_acc_test.go b/resources/dw/virtualwarehouse/hive/resource_hive_vw_acc_test.go index 9935eea5..2b1a6e5c 100644 --- a/resources/dw/virtualwarehouse/hive/resource_hive_vw_acc_test.go +++ b/resources/dw/virtualwarehouse/hive/resource_hive_vw_acc_test.go @@ -1,4 +1,4 @@ -// Copyright 2024 Cloudera. All Rights Reserved. +// Copyright 2025 Cloudera. All Rights Reserved. // // This file is licensed under the Apache License Version 2.0 (the "License"). // You may not use this file except in compliance with the License. @@ -44,7 +44,7 @@ func HivePreCheck(t *testing.T) { } } -func TestAccHive_basic(t *testing.T) { +func TestAccHive_Basic(t *testing.T) { params := hiveTestParameters{ Name: cdpacctest.RandomShortWithPrefix(cdpacctest.ResourcePrefix), ClusterID: os.Getenv("CDW_CLUSTER_ID"), @@ -81,7 +81,7 @@ func TestAccHive_basic(t *testing.T) { func testAccHiveBasicConfig(params hiveTestParameters) string { return fmt.Sprintf(` resource "cdp_dw_vw_hive" "test_hive" { - cluster_id = %[1]q + cluster_id = %[1]q database_catalog_id = %[2]q name = %[3]q group_size = 2 diff --git a/templates/resources/dw_data_visualization.md.tmpl b/templates/resources/dw_data_visualization.md.tmpl new file mode 100644 index 00000000..48bbee41 --- /dev/null +++ b/templates/resources/dw_data_visualization.md.tmpl @@ -0,0 +1,26 @@ +--- +page_title: "{{.Name}} {{.Type}} - {{.ProviderName}}" +subcategory: "Data Warehouse" +description: |- +{{ .Description | plainmarkdown | trimspace | prefixlines " " }} +--- + +# {{.Name}} ({{.Type}}) + +{{ .Description | trimspace }} + +{{ if .HasExample -}} +## Example Usage + +{{ tffile .ExampleFile }} +{{- end }} + +{{ .SchemaMarkdown | trimspace }} + +{{- if .HasImport }} +## Import + +Import is supported using the following syntax: + +{{codefile "shell" .ImportFile }} +{{- end }} diff --git a/utils/utils.go b/utils/utils.go index 2f33d979..78bc8213 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -16,6 +16,7 @@ import ( "math" "time" + "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/types" @@ -24,6 +25,12 @@ import ( "github.com/cloudera/terraform-provider-cdp/cdp-sdk-go/cdp" ) +// TODO vcsomor: This file contains utility methods for multiple purposes, such as +// - credential handling +// - TF <-> GO Data Object transformations +// - Timeout handling +// TODO vcsomor: I'd good to separate these by purpose + func GetCdpClientForResource(req resource.ConfigureRequest, resp *resource.ConfigureResponse) *cdp.Client { if req.ProviderData == nil { return nil @@ -92,6 +99,21 @@ func CalculateCallFailureThresholdOrDefault(ctx context.Context, options *Pollin return threshold, nil } +func FromStringListToListValue(s []string) types.List { + if s == nil { + return types.ListNull(types.StringType) + } + + var elems []attr.Value + elems = make([]attr.Value, 0, len(s)) + for _, v := range s { + elems = append(elems, types.StringValue(v)) + } + var list types.List + list, _ = types.ListValue(types.StringType, elems) + return list +} + func FromListValueToStringList(tl types.List) []string { if tl.IsNull() { return []string{} diff --git a/utils/utils_test.go b/utils/utils_test.go index 90f2a2b9..64592923 100644 --- a/utils/utils_test.go +++ b/utils/utils_test.go @@ -15,6 +15,7 @@ import ( "testing" "time" + "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/stretchr/testify/assert" ) @@ -346,3 +347,33 @@ func TestFromTfStringSliceToStringSliceIfNotEmpty(t *testing.T) { assert.Equal(t, len(input), len(result)) assert.Equal(t, val, result[0]) } + +func TestFromStringListToListValue(t *testing.T) { + assert.Equal( + t, + types.ListNull(types.StringType), + FromStringListToListValue(nil), + ) + + expected, err := types.ListValue(types.StringType, []attr.Value{}) + assert.Nil(t, err) + assert.Equal( + t, + expected, + FromStringListToListValue([]string{}), + ) + + expected, err = types.ListValue( + types.StringType, + []attr.Value{ + types.StringValue("test0"), + types.StringValue("test1"), + }, + ) + assert.Nil(t, err) + assert.Equal( + t, + expected, + FromStringListToListValue([]string{"test0", "test1"}), + ) +}