From 3bbe7d0a5a88d289f156c9f808f5a5af1a9c817d Mon Sep 17 00:00:00 2001 From: hcch0 <157843901+hcch0@users.noreply.github.com> Date: Fri, 12 Apr 2024 09:40:02 -0700 Subject: [PATCH] Implement full support for importing service and private endpoint registration (#54) --- .gitignore | 1 + clickhouse/private_endpoint_registration.go | 17 +- clickhouse/service.go | 248 +++++++++----------- 3 files changed, 127 insertions(+), 139 deletions(-) diff --git a/.gitignore b/.gitignore index f3e2f010..bdce5890 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ terraform.tfstate.backup node_modules/ secrets.tfvars dev/ +.idea/ diff --git a/clickhouse/private_endpoint_registration.go b/clickhouse/private_endpoint_registration.go index 10625bde..309abd5c 100644 --- a/clickhouse/private_endpoint_registration.go +++ b/clickhouse/private_endpoint_registration.go @@ -2,7 +2,6 @@ package clickhouse import ( "context" - "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" @@ -39,7 +38,7 @@ func (r *PrivateEndpointRegistrationResource) Schema(_ context.Context, _ resour Attributes: map[string]schema.Attribute{ "cloud_provider": schema.StringAttribute{ Description: "Cloud provider of the private endpoint ID", - Required: true, + Required: true, }, "description": schema.StringAttribute{ Description: "Description of the private endpoint", @@ -122,11 +121,11 @@ func (r *PrivateEndpointRegistrationResource) Read(ctx context.Context, req reso var privateEndpoint *PrivateEndpoint for _, pe := range *privateEndpoints { - if pe.CloudProvider == state.CloudProvider.ValueString() && - pe.EndpointId == state.EndpointId.ValueString() && - pe.Region == state.Region.ValueString() { - privateEndpoint = &pe - break + + // openapi validator guarantees uniqueness by ID + if pe.EndpointId == state.EndpointId.ValueString() { + privateEndpoint = &pe + break } } @@ -136,6 +135,8 @@ func (r *PrivateEndpointRegistrationResource) Read(ctx context.Context, req reso } state.Description = types.StringValue(privateEndpoint.Description) + state.Region = types.StringValue(privateEndpoint.Region) + state.CloudProvider = types.StringValue(privateEndpoint.CloudProvider) diags = resp.State.Set(ctx, &state) resp.Diagnostics.Append(diags...) @@ -153,7 +154,7 @@ func (r *PrivateEndpointRegistrationResource) Update(ctx context.Context, req re orgUpdate := OrganizationUpdate{ PrivateEndpoints: &OrgPrivateEndpointsUpdate{ - Add: []PrivateEndpoint{ + Add: []PrivateEndpoint{ { CloudProvider: plan.CloudProvider.ValueString(), Description: plan.Description.ValueString(), diff --git a/clickhouse/service.go b/clickhouse/service.go index cfda1bd1..acf41dd4 100644 --- a/clickhouse/service.go +++ b/clickhouse/service.go @@ -2,6 +2,7 @@ package clickhouse import ( "context" + "errors" "strings" "time" @@ -35,24 +36,24 @@ type ServiceResource struct { } type ServiceResourceModel struct { - ID types.String `tfsdk:"id"` - Name types.String `tfsdk:"name"` - Password types.String `tfsdk:"password"` - PasswordHash types.String `tfsdk:"password_hash"` - DoubleSha1PasswordHash types.String `tfsdk:"double_sha1_password_hash"` - Endpoints types.List `tfsdk:"endpoints"` - CloudProvider types.String `tfsdk:"cloud_provider"` - Region types.String `tfsdk:"region"` - Tier types.String `tfsdk:"tier"` - IdleScaling types.Bool `tfsdk:"idle_scaling"` - IpAccessList []IpAccessModel `tfsdk:"ip_access"` - MinTotalMemoryGb types.Int64 `tfsdk:"min_total_memory_gb"` - MaxTotalMemoryGb types.Int64 `tfsdk:"max_total_memory_gb"` - IdleTimeoutMinutes types.Int64 `tfsdk:"idle_timeout_minutes"` - IAMRole types.String `tfsdk:"iam_role"` - LastUpdated types.String `tfsdk:"last_updated"` - PrivateEndpointConfig types.Object `tfsdk:"private_endpoint_config"` - PrivateEndpointIds types.List `tfsdk:"private_endpoint_ids"` + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Password types.String `tfsdk:"password"` + PasswordHash types.String `tfsdk:"password_hash"` + DoubleSha1PasswordHash types.String `tfsdk:"double_sha1_password_hash"` + Endpoints types.List `tfsdk:"endpoints"` + CloudProvider types.String `tfsdk:"cloud_provider"` + Region types.String `tfsdk:"region"` + Tier types.String `tfsdk:"tier"` + IdleScaling types.Bool `tfsdk:"idle_scaling"` + IpAccessList []IpAccessModel `tfsdk:"ip_access"` + MinTotalMemoryGb types.Int64 `tfsdk:"min_total_memory_gb"` + MaxTotalMemoryGb types.Int64 `tfsdk:"max_total_memory_gb"` + IdleTimeoutMinutes types.Int64 `tfsdk:"idle_timeout_minutes"` + IAMRole types.String `tfsdk:"iam_role"` + LastUpdated types.String `tfsdk:"last_updated"` + PrivateEndpointConfig types.Object `tfsdk:"private_endpoint_config"` + PrivateEndpointIds types.List `tfsdk:"private_endpoint_ids"` EncryptionKey types.String `tfsdk:"encryption_key"` EncryptionAssumedRoleIdentifier types.String `tfsdk:"encryption_assumed_role_identifier"` } @@ -87,8 +88,8 @@ func (r *ServiceResource) Schema(_ context.Context, _ resource.SchemaRequest, re resp.Schema = schema.Schema{ Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ - Description: "ID of the created service. Generated by ClickHouse Cloud.", - Computed: true, + Description: "ID of the created service. Generated by ClickHouse Cloud.", + Computed: true, PlanModifiers: []planmodifier.String{ stringplanmodifier.UseStateForUnknown(), }, @@ -113,8 +114,8 @@ func (r *ServiceResource) Schema(_ context.Context, _ resource.SchemaRequest, re }, "double_sha1_password_hash": schema.StringAttribute{ Description: "Double SHA1 hash of password for connecting with the MySQL protocol. Cannot be specified if `password` is specified.", - Optional: true, - Sensitive: true, + Optional: true, + Sensitive: true, }, "cloud_provider": schema.StringAttribute{ Description: "Cloud provider ('aws', 'gcp', or 'azure') in which the service is deployed in.", @@ -133,8 +134,8 @@ func (r *ServiceResource) Schema(_ context.Context, _ resource.SchemaRequest, re Optional: true, }, "ip_access": schema.ListNestedAttribute{ - Description: "List of IP addresses allowed to access the service.", - Required: true, + Description: "List of IP addresses allowed to access the service.", + Required: true, NestedObject: schema.NestedAttributeObject{ Attributes: map[string]schema.Attribute{ "source": schema.StringAttribute{ @@ -149,8 +150,8 @@ func (r *ServiceResource) Schema(_ context.Context, _ resource.SchemaRequest, re }, }, "endpoints": schema.ListNestedAttribute{ - Description: "List of public endpoints.", - Computed: true, + Description: "List of public endpoints.", + Computed: true, NestedObject: schema.NestedAttributeObject{ Attributes: map[string]schema.Attribute{ "protocol": schema.StringAttribute{ @@ -190,7 +191,7 @@ func (r *ServiceResource) Schema(_ context.Context, _ resource.SchemaRequest, re "private_endpoint_config": schema.SingleNestedAttribute{ Description: "Service config for private endpoints", Computed: true, - Attributes: map[string]schema.Attribute{ + Attributes: map[string]schema.Attribute{ "endpoint_service_id": schema.StringAttribute{ Description: "Unique identifier of the interface endpoint you created in your VPC with the AWS(Service Name) or GCP(Target Service) resource", Computed: true, @@ -404,8 +405,7 @@ func (r *ServiceResource) Create(ctx context.Context, req resource.CreateRequest } // Update hashed service password if provided explicitly - if passwordHash, doubleSha1PasswordHash := plan.PasswordHash.ValueString(), plan.DoubleSha1PasswordHash.ValueString(); - len(passwordHash) > 0 || len(doubleSha1PasswordHash) > 0 { + if passwordHash, doubleSha1PasswordHash := plan.PasswordHash.ValueString(), plan.DoubleSha1PasswordHash.ValueString(); len(passwordHash) > 0 || len(doubleSha1PasswordHash) > 0 { passwordUpdate := ServicePasswordUpdate{ NewPasswordHash: passwordHash, } @@ -426,62 +426,13 @@ func (r *ServiceResource) Create(ctx context.Context, req resource.CreateRequest // Map response body to schema and populate Computed attribute values plan.ID = types.StringValue(s.Id) - plan.Name = types.StringValue(s.Name) - plan.CloudProvider = types.StringValue(s.Provider) - plan.Region = types.StringValue(s.Region) - plan.Tier = types.StringValue(s.Tier) - - if s.Tier == "production" { - plan.IdleScaling = types.BoolValue(s.IdleScaling) - - if !plan.MinTotalMemoryGb.IsNull() { - plan.MinTotalMemoryGb = types.Int64Value(int64(*s.MinTotalMemoryGb)) - } - if !plan.MaxTotalMemoryGb.IsNull() { - plan.MaxTotalMemoryGb = types.Int64Value(int64(*s.MaxTotalMemoryGb)) - } - if !plan.IdleTimeoutMinutes.IsNull() { - plan.IdleTimeoutMinutes = types.Int64Value(int64(*s.IdleTimeoutMinutes)) - } - } - - for ipAccessIndex, ipAccess := range s.IpAccessList { - stateIpAccess := IpAccessModel{ - Source: types.StringValue(ipAccess.Source), - } - - if (!plan.IpAccessList[ipAccessIndex].Description.IsNull()) { - stateIpAccess.Description = types.StringValue(ipAccess.Description) - } - - plan.IpAccessList[ipAccessIndex] = stateIpAccess - } - - var values []attr.Value - for _, endpoint := range s.Endpoints { - obj, _ := types.ObjectValue(endpointObjectType.AttrTypes, map[string]attr.Value{ - "protocol": types.StringValue(endpoint.Protocol), - "host": types.StringValue(endpoint.Host), - "port": types.Int64Value(int64(endpoint.Port)), - }) - - values = append(values, obj) - } - plan.Endpoints, _ = types.ListValue(endpointObjectType, values) - - plan.LastUpdated = types.StringValue(time.Now().Format(time.RFC850)) - plan.IAMRole = types.StringValue(s.IAMRole) - - plan.PrivateEndpointConfig, _ = types.ObjectValue(privateEndpointConfigType.AttrTypes, map[string]attr.Value{ - "endpoint_service_id": types.StringValue(s.PrivateEndpointConfig.EndpointServiceId), - "private_dns_hostname": types.StringValue(s.PrivateEndpointConfig.PrivateDnsHostname), - }) - - // default null config value to empty string array - if plan.PrivateEndpointIds.IsNull() { - plan.PrivateEndpointIds = createEmptyList(types.StringType) - } else { - plan.PrivateEndpointIds, _ = types.ListValueFrom(ctx, types.StringType, s.PrivateEndpointIds) + err = r.syncServiceState(ctx, &plan) + if err != nil { + resp.Diagnostics.AddError( + "Error Reading ClickHouse Service", + "Could not read ClickHouse service id "+plan.ID.ValueString()+": "+err.Error(), + ) + return } // Set state to fully populated data @@ -501,8 +452,7 @@ func (r *ServiceResource) Read(ctx context.Context, req resource.ReadRequest, re return } - // Get refreshed service value from ClickHouse OpenAPI - service, err := r.client.GetService(state.ID.ValueString()) + err := r.syncServiceState(ctx, &state) if err != nil { resp.Diagnostics.AddError( "Error Reading ClickHouse Service", @@ -511,48 +461,6 @@ func (r *ServiceResource) Read(ctx context.Context, req resource.ReadRequest, re return } - // Overwrite items with refreshed state - newIpAccess := []IpAccessModel{} - for index, item := range service.IpAccessList { - stateIpAccess := IpAccessModel{ - Source: types.StringValue(item.Source), - } - - if (!(item.Description == "" && state.IpAccessList[index].Description.IsNull())) { - stateIpAccess.Description = types.StringValue(item.Description) - } - - newIpAccess = append(newIpAccess, stateIpAccess) - } - state.IpAccessList = newIpAccess - - var values []attr.Value - for _, endpoint := range service.Endpoints { - obj, _ := types.ObjectValue(endpointObjectType.AttrTypes, map[string]attr.Value{ - "protocol": types.StringValue(endpoint.Protocol), - "host": types.StringValue(endpoint.Host), - "port": types.Int64Value(int64(endpoint.Port)), - }) - - values = append(values, obj) - } - state.Endpoints, _ = types.ListValue(endpointObjectType, values) - - state.PrivateEndpointConfig, _ = types.ObjectValue(privateEndpointConfigType.AttrTypes, map[string]attr.Value{ - "endpoint_service_id": types.StringValue(service.PrivateEndpointConfig.EndpointServiceId), - "private_dns_hostname": types.StringValue(service.PrivateEndpointConfig.PrivateDnsHostname), - }) - - state.EncryptionKey = types.StringValue(service.EncryptionKey) - state.EncryptionAssumedRoleIdentifier = types.StringValue(service.EncryptionAssumedRoleIdentifier) - - // default null config value to empty string array - if state.PrivateEndpointIds.IsNull() { - state.PrivateEndpointIds = createEmptyList(types.StringType) - } else { - state.PrivateEndpointIds, _ = types.ListValueFrom(ctx, types.StringType, service.PrivateEndpointIds) - } - // Set refreshed state diags = resp.State.Set(ctx, &state) resp.Diagnostics.Append(diags...) @@ -884,10 +792,10 @@ func (r *ServiceResource) Update(ctx context.Context, req resource.UpdateRequest for ipAccessIndex, ipAccess := range s.IpAccessList { stateIpAccess := IpAccessModel{ - Source: types.StringValue(ipAccess.Source), + Source: types.StringValue(ipAccess.Source), } - if (!plan.IpAccessList[ipAccessIndex].Description.IsNull()) { + if !plan.IpAccessList[ipAccessIndex].Description.IsNull() { stateIpAccess.Description = types.StringValue(ipAccess.Description) } @@ -953,3 +861,81 @@ func (r *ServiceResource) ImportState(ctx context.Context, req resource.ImportSt // Retrieve import ID and save to id attribute resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) } + +// syncServiceState fetches the latest state ClickHouse Cloud API and updates the Terraform state. +func (r *ServiceResource) syncServiceState(ctx context.Context, state *ServiceResourceModel) error { + if state.ID.IsNull() { + return errors.New("service ID must be reset to fetch the service") + } + + // Get latest service value from ClickHouse OpenAPI + service, err := r.client.GetService(state.ID.ValueString()) + if err != nil { + return err + } + + // Overwrite items with refreshed state + state.Name = types.StringValue(service.Name) + state.CloudProvider = types.StringValue(service.Provider) + state.Region = types.StringValue(service.Region) + state.Tier = types.StringValue(service.Tier) + + if service.Tier == "production" { + state.IdleScaling = types.BoolValue(service.IdleScaling) + state.MinTotalMemoryGb = types.Int64Value(int64(*service.MinTotalMemoryGb)) + state.MaxTotalMemoryGb = types.Int64Value(int64(*service.MaxTotalMemoryGb)) + state.IdleTimeoutMinutes = types.Int64Value(int64(*service.IdleTimeoutMinutes)) + } + + ipAccessList := []IpAccessModel{} + for index, ipAccess := range service.IpAccessList { + stateIpAccess := IpAccessModel{ + Source: types.StringValue(ipAccess.Source), + } + + // the API does not differentiate between undefined and empty string + if ipAccess.Description == "" { + // we will set this field as "" when user defines the description field + isDescriptionNull := index > len(state.IpAccessList) || state.IpAccessList[index].Description.IsNull() + if !isDescriptionNull { + stateIpAccess.Description = types.StringValue("") + } + } else { + stateIpAccess.Description = types.StringValue(ipAccess.Description) + } + + ipAccessList = append(ipAccessList, stateIpAccess) + } + state.IpAccessList = ipAccessList + + var endpoints []attr.Value + for _, endpoint := range service.Endpoints { + obj, _ := types.ObjectValue(endpointObjectType.AttrTypes, map[string]attr.Value{ + "protocol": types.StringValue(endpoint.Protocol), + "host": types.StringValue(endpoint.Host), + "port": types.Int64Value(int64(endpoint.Port)), + }) + + endpoints = append(endpoints, obj) + } + state.Endpoints, _ = types.ListValue(endpointObjectType, endpoints) + + state.LastUpdated = types.StringValue(time.Now().Format(time.RFC850)) + state.IAMRole = types.StringValue(service.IAMRole) + + state.PrivateEndpointConfig, _ = types.ObjectValue(privateEndpointConfigType.AttrTypes, map[string]attr.Value{ + "endpoint_service_id": types.StringValue(service.PrivateEndpointConfig.EndpointServiceId), + "private_dns_hostname": types.StringValue(service.PrivateEndpointConfig.PrivateDnsHostname), + }) + + state.EncryptionKey = types.StringValue(service.EncryptionKey) + state.EncryptionAssumedRoleIdentifier = types.StringValue(service.EncryptionAssumedRoleIdentifier) + + if len(service.PrivateEndpointIds) == 0 { + state.PrivateEndpointIds = createEmptyList(types.StringType) + } else { + state.PrivateEndpointIds, _ = types.ListValueFrom(ctx, types.StringType, service.PrivateEndpointIds) + } + + return nil +}