From 751184d9544b430f4f4e7ca4437ce83c3741703e Mon Sep 17 00:00:00 2001 From: cdavid Date: Thu, 5 Oct 2023 08:36:49 -0700 Subject: [PATCH] Public access setting (#78) * Public access setting * Fix docs * Fix the cluster edit flow without any real edit * Add example for RF5 cluster * Revert "Add example for RF5 cluster" This reverts commit 5f1b52ead100cdd889b1d2948bd6d5280ea58cdc. * make doc --------- Co-authored-by: Arnav Agarwal <14933889+Arnav15@users.noreply.github.com> --- docs/resources/cluster.md | 72 ++++++++++- .../single-region-public-access.tf | 51 ++++++++ managed/models.go | 16 ++- managed/resource_associate_me_cluster.go | 6 + managed/resource_cluster.go | 120 +++++++++++++++++- templates/resources/cluster.md.tmpl | 6 +- 6 files changed, 259 insertions(+), 12 deletions(-) create mode 100644 examples/resources/ybm_cluster/single-region-public-access.tf diff --git a/docs/resources/cluster.md b/docs/resources/cluster.md index eb63230..197d19e 100644 --- a/docs/resources/cluster.md +++ b/docs/resources/cluster.md @@ -169,7 +169,63 @@ resource "ybm_cluster" "multi_region_cluster" { } ``` -To create a multi-region cluster which supports upto 2 domain faults (RF 5) +To create a single region cluster in a dedicated VPC with public access + +```terraform +# Cluster with single region + +variable "password" { + type = string + description = "YSQL Password." + sensitive = true +} + +resource "ybm_vpc" "example-vpc" { + name = "example-vpc" + cloud = "AWS" + region_cidr_info = [ + { + region = "us-east-1" + cidr = "10.231.0.0/24" + } + ] +} + +resource "ybm_allow_list" "example_allow_list" { + allow_list_name = "allow-nobody" + allow_list_description = "allow 192.168.0.1" + cidr_list = ["192.168.0.1/32"] +} + + +resource "ybm_cluster" "single_region_cluster" { + cluster_name = "single-region-cluster" + cloud_type = "AWS" + cluster_type = "SYNCHRONOUS" + cluster_region_info = [ + { + region = "us-east-1" + num_nodes = 1 + vpc_id = ybm_vpc.example-vpc.vpc_id + public_access = true + } + ] + cluster_tier = "PAID" + cluster_allow_list_ids = [ybm_allow_list.example_allow_list.allow_list_id] + fault_tolerance = "NONE" + node_config = { + num_cores = 4 + disk_size_gb = 50 + } + credentials = { + username = "example_ysql_user" + password = var.password + } + +} +``` + +To create a multi-region cluster which supports up to 2 domain faults (RF 5) ```terraform variable "password" { @@ -557,11 +613,12 @@ resource "ybm_private_service_endpoint" "npsenonok-region" { - `backup_schedules` (Attributes List) (see [below for nested schema](#nestedatt--backup_schedules)) - `cloud_type` (String) The cloud provider where the cluster is deployed: AWS, AZURE or GCP. - `cluster_allow_list_ids` (List of String) List of IDs of the allow lists assigned to the cluster. -- `cluster_endpoints` (Map of String) The endpoints used to connect to the cluster by region. +- `cluster_endpoints` (Map of String, Deprecated) The endpoints used to connect to the cluster. - `cluster_id` (String) The ID of the cluster. Created automatically when a cluster is created. Used to get a specific cluster. - `cmk_spec` (Attributes) KMS Provider Configuration. (see [below for nested schema](#nestedatt--cmk_spec)) - `database_track` (String) The track of the database. Production or Innovation or Preview. - `desired_state` (String) The desired state of the database, Active or Paused. This parameter can be used to pause/resume a cluster. +- `endpoints` (Attributes List) The endpoints used to connect to the cluster. (see [below for nested schema](#nestedatt--endpoints)) - `fault_tolerance` (String) The fault tolerance of the cluster. NONE, NODE, ZONE or REGION. - `num_faults_to_tolerate` (Number) The number of domain faults the cluster can tolerate. 0 for NONE, 1 for ZONE and [1-3] for NODE and REGION - `restore_backup_id` (String) The ID of the backup to be restored to the cluster. @@ -584,6 +641,7 @@ Required: Optional: +- `public_access` (Boolean) - `vpc_id` (String) - `vpc_name` (String) @@ -684,6 +742,16 @@ Optional: + +### Nested Schema for `endpoints` + +Optional: + +- `accessibility_type` (String) The accessibility type of the endpoint. PUBLIC or PRIVATE. +- `host` (String) The host of the endpoint. +- `region` (String) The region of the endpoint. + + ### Nested Schema for `cluster_info` diff --git a/examples/resources/ybm_cluster/single-region-public-access.tf b/examples/resources/ybm_cluster/single-region-public-access.tf new file mode 100644 index 0000000..4414ca8 --- /dev/null +++ b/examples/resources/ybm_cluster/single-region-public-access.tf @@ -0,0 +1,51 @@ +# Cluster with single region + +variable "password" { + type = string + description = "YSQL Password." + sensitive = true +} + +resource "ybm_vpc" "example-vpc" { + name = "example-vpc" + cloud = "AWS" + region_cidr_info = [ + { + region = "us-east-1" + cidr = "10.231.0.0/24" + } + ] +} + +resource "ybm_allow_list" "example_allow_list" { + allow_list_name = "allow-nobody" + allow_list_description = "allow 192.168.0.1" + cidr_list = ["192.168.0.1/32"] +} + + +resource "ybm_cluster" "single_region_cluster" { + cluster_name = "single-region-cluster" + cloud_type = "AWS" + cluster_type = "SYNCHRONOUS" + cluster_region_info = [ + { + region = "us-east-1" + num_nodes = 1 + vpc_id = ybm_vpc.example-vpc.vpc_id + public_access = true + } + ] + cluster_tier = "PAID" + cluster_allow_list_ids = [ybm_allow_list.example_allow_list.allow_list_id] + fault_tolerance = "NONE" + node_config = { + num_cores = 4 + disk_size_gb = 50 + } + credentials = { + username = "example_ysql_user" + password = var.password + } + +} diff --git a/managed/models.go b/managed/models.go index f00bc69..3d542a2 100644 --- a/managed/models.go +++ b/managed/models.go @@ -29,10 +29,17 @@ type Cluster struct { ClusterVersion types.String `tfsdk:"cluster_version"` BackupSchedules []BackupScheduleInfo `tfsdk:"backup_schedules"` ClusterEndpoints types.Map `tfsdk:"cluster_endpoints"` + ClusterEndpointsV2 []ClusterEndpoint `tfsdk:"endpoints"` ClusterCertificate types.String `tfsdk:"cluster_certificate"` CMKSpec *CMKSpec `tfsdk:"cmk_spec"` } +type ClusterEndpoint struct { + AccessibilityType types.String `tfsdk:"accessibility_type"` + Host types.String `tfsdk:"host"` + Region types.String `tfsdk:"region"` +} + type CMKSpec struct { ProviderType types.String `tfsdk:"provider_type"` AWSCMKSpec *AWSCMKSpec `tfsdk:"aws_cmk_spec"` @@ -76,10 +83,11 @@ type BackupScheduleInfo struct { TimeIntervalInDays types.Int64 `tfsdk:"time_interval_in_days"` } type RegionInfo struct { - Region types.String `tfsdk:"region"` - NumNodes types.Int64 `tfsdk:"num_nodes"` - VPCID types.String `tfsdk:"vpc_id"` - VPCName types.String `tfsdk:"vpc_name"` + Region types.String `tfsdk:"region"` + NumNodes types.Int64 `tfsdk:"num_nodes"` + VPCID types.String `tfsdk:"vpc_id"` + VPCName types.String `tfsdk:"vpc_name"` + PublicAccess types.Bool `tfsdk:"public_access"` } type NodeConfig struct { diff --git a/managed/resource_associate_me_cluster.go b/managed/resource_associate_me_cluster.go index d790956..a6352b1 100644 --- a/managed/resource_associate_me_cluster.go +++ b/managed/resource_associate_me_cluster.go @@ -288,6 +288,12 @@ func getTaskState(accountId string, projectId string, entityId string, entityTyp if v, ok := taskList.GetDataOk(); ok && v != nil { c := taskList.GetData() + + if len(c) == 0 { + tflog.Info(ctx, "No task found for this operation") + return "TASK_NOT_FOUND", true, "" + } + if len(c) > 0 { if status, ok := c[0].GetInfoOk(); ok { currentStatus = status.GetState() diff --git a/managed/resource_cluster.go b/managed/resource_cluster.go index 5304aa2..9d9ab22 100644 --- a/managed/resource_cluster.go +++ b/managed/resource_cluster.go @@ -92,6 +92,11 @@ and modify the backup schedule of the cluster being created.`, Optional: true, Computed: true, }, + "public_access": { + Type: types.BoolType, + Optional: true, + Computed: true, + }, }), }, "backup_schedules": { @@ -404,13 +409,39 @@ and modify the backup schedule of the cluster being created.`, }, }, "cluster_endpoints": { - Description: "The endpoints used to connect to the cluster by region.", + Description: "The endpoints used to connect to the cluster.", + DeprecationMessage: "This attribute is deprecated. Please use the 'endpoints' attribute instead.", Type: types.MapType{ ElemType: types.StringType, }, Optional: true, Computed: true, }, + "endpoints": { + Description: "The endpoints used to connect to the cluster.", + Attributes: tfsdk.ListNestedAttributes(map[string]tfsdk.Attribute{ + "accessibility_type": { + Description: "The accessibility type of the endpoint. PUBLIC or PRIVATE.", + Type: types.StringType, + Computed: true, + Optional: true, + }, + "host": { + Description: "The host of the endpoint.", + Type: types.StringType, + Computed: true, + Optional: true, + }, + "region": { + Description: "The region of the endpoint.", + Type: types.StringType, + Computed: true, + Optional: true, + }, + }), + Computed: true, + Optional: true, + }, "cluster_certificate": { Description: "The certificate used to connect to the cluster.", Type: types.StringType, @@ -502,9 +533,33 @@ func createClusterSpec(ctx context.Context, apiClient *openapiclient.APIClient, regionInfo.VPCID.Value = vpcData.Info.Id } + + // Create an array of AccessibilityType and populate it according to + // the following logic: + // if the cluster is in a private VPC, it MUST always have PRIVATE. + // if the cluster is NOT in a private VPC, it MUST always have PUBLIC. + // if the cluster is in a private VPC and customer wants public access, it MUST have PRIVATE and PUBLIC. + accessibilityTypes := []openapiclient.AccessibilityType{} + if vpcID := regionInfo.VPCID.Value; vpcID != "" { info.PlacementInfo.SetVpcId(vpcID) + accessibilityTypes = append(accessibilityTypes, openapiclient.ACCESSIBILITYTYPE_PRIVATE) + + if regionInfo.PublicAccess.Value { + accessibilityTypes = append(accessibilityTypes, openapiclient.ACCESSIBILITYTYPE_PUBLIC) + } + } else { + accessibilityTypes = append(accessibilityTypes, openapiclient.ACCESSIBILITYTYPE_PUBLIC) + + if !regionInfo.PublicAccess.Value { + tflog.Debug(ctx, fmt.Sprintf("Cluster %v is in a public VPC and public access is disabled. ", plan.ClusterName.Value)) + return nil, false, "Cluster is in a public VPC and public access is disabled. Please enable public access." + } } + + // Set the accessibility type for the region + info.SetAccessibilityTypes(accessibilityTypes) + if clusterType == "SYNCHRONOUS" { info.PlacementInfo.SetMultiZone(false) } @@ -1361,6 +1416,21 @@ func resourceClusterRead(ctx context.Context, accountId string, projectId string } cluster.ClusterEndpoints = clusterEndpoints + // Cluster endpoints v2 + var clusterEndpointsV2 []ClusterEndpoint + for _, val := range clusterResp.Data.Info.ClusterEndpoints { + + tflog.Debug(ctx, fmt.Sprintf("Cluster Endpoint %v %v %v", val.GetAccessibilityType(), val.GetHost(), val.Region)) + + clusterEndpoint := ClusterEndpoint{ + AccessibilityType: types.String{Value: string(val.GetAccessibilityType())}, + Host: types.String{Value: val.GetHost()}, + Region: types.String{Value: val.Region}, + } + clusterEndpointsV2 = append(clusterEndpointsV2, clusterEndpoint) + } + cluster.ClusterEndpointsV2 = clusterEndpointsV2 + // Cluster certificate certResponse, certHttpResp, err := apiClient.ClusterApi.GetConnectionCertificate(context.Background()).Execute() if err != nil { @@ -1390,11 +1460,24 @@ func resourceClusterRead(ctx context.Context, accountId string, projectId string } vpcName = vpcData.Spec.Name } + + // if info.AccessibilityTypes contains "PUBLIC" then set PublicAccess to true + publicAccess := false + for _, accessibilityType := range info.GetAccessibilityTypes() { + if accessibilityType == "PUBLIC" { + publicAccess = true + break + } + } + + tflog.Debug(ctx, fmt.Sprintf("For region %v, publicAccess = %v", region, publicAccess)) + regionInfo := RegionInfo{ - Region: types.String{Value: region}, - NumNodes: types.Int64{Value: int64(info.PlacementInfo.GetNumNodes())}, - VPCID: types.String{Value: vpcID}, - VPCName: types.String{Value: vpcName}, + Region: types.String{Value: region}, + NumNodes: types.Int64{Value: int64(info.PlacementInfo.GetNumNodes())}, + VPCID: types.String{Value: vpcID}, + VPCName: types.String{Value: vpcName}, + PublicAccess: types.Bool{Value: publicAccess}, } clusterRegionInfo[destIndex] = regionInfo } @@ -1584,10 +1667,27 @@ func (r resourceCluster) Update(ctx context.Context, req tfsdk.UpdateResourceReq return } + // The following code has a pitfall: + // If we change just the cluster_allow_list_ids field, then we will send a cluster edit + // request to the server. The server will see the spec is the same as the current spec, + // so there will be no task submitted. + // If there is no task submitted (EVER), we will get a TASK_NOT_FOUND. + // If there was EVER a task submitted, we will get the status of that task (likely SUCCESS). + // + // Challenges: + // 1. Last EDIT was not successful - the customer should first perform an edit to get out of that state. + // 2. To work around ANY possible race condition in the server side (task created AFTER the response), + // we will try twice to read the task state. If both times we can't find the task, we bail out. + // + // Something similar will happen if changing the backup schedule or the CMK spec. + var retries int retryPolicy := retry.NewConstant(10 * time.Second) retryPolicy = retry.WithMaxDuration(3600*time.Second, retryPolicy) err = retry.Do(ctx, retryPolicy, func(ctx context.Context) error { asState, readInfoOK, message := getTaskState(accountId, projectId, clusterId, openapiclient.ENTITYTYPEENUM_CLUSTER, openapiclient.TASKTYPEENUM_EDIT_CLUSTER, apiClient, ctx) + + tflog.Info(ctx, "Cluster edit operation in progress, state: "+asState) + if readInfoOK { if asState == string(openapiclient.TASKACTIONSTATEENUM_SUCCEEDED) { return nil @@ -1595,6 +1695,16 @@ func (r resourceCluster) Update(ctx context.Context, req tfsdk.UpdateResourceReq if asState == string(openapiclient.TASKACTIONSTATEENUM_FAILED) { return ErrFailedTask } + if asState == "TASK_NOT_FOUND" { + if retries < 2 { + retries++ + tflog.Info(ctx, "Cluster edit task not found, retrying...") + return retry.RetryableError(errors.New("Cluster not found, retrying")) + } else { + tflog.Info(ctx, "Cluster edit task not found, giving up...") + return nil + } + } } else { return retry.RetryableError(errors.New("Unable to get cluster state: " + message)) } diff --git a/templates/resources/cluster.md.tmpl b/templates/resources/cluster.md.tmpl index 1f7b3d0..0d6e80e 100644 --- a/templates/resources/cluster.md.tmpl +++ b/templates/resources/cluster.md.tmpl @@ -23,7 +23,11 @@ To create a multi region cluster by using common credentials for both YSQL and Y {{ tffile "examples/resources/ybm_cluster/multi-region-common-credentials.tf" }} -To create a multi-region cluster which supports upto 2 domain faults (RF 5) +To create a single region cluster in a dedicated VPC with public access + +{{ tffile "examples/resources/ybm_cluster/single-region-public-access.tf" }} + +To create a multi-region cluster which supports up to 2 domain faults (RF 5) {{ tffile "examples/resources/ybm_cluster/multi-region-rf5.tf" }}