diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4c8647af..3cc7f3fe 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,8 +1,6 @@ name: Cockroach Terraform Provider CI on: pull_request: - branches: - - 'main' jobs: diff --git a/CHANGELOG.md b/CHANGELOG.md index 6470ae10..6defdda0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## Added + +- Added `delete_protection` to the Cluster resource and data source. When set + to true, attempts to delete the cluster will fail. Set to false to disable + delete protection. + +## [1.5.0] - 2024-05-26 + +- No changes. + ## [1.4.1] - 2024-04-04 ## Added diff --git a/Makefile b/Makefile index d1069c5b..b6866c11 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ HOSTNAME=registry.terraform.io NAMESPACE=cockroachdb NAME=cockroach BINARY=terraform-provider-${NAME} -VERSION=0.4.3 +VERSION=1.6.0 OS_ARCH=darwin_amd64 default: install diff --git a/docs/data-sources/cluster.md b/docs/data-sources/cluster.md index 49f34aea..f168d8d4 100644 --- a/docs/data-sources/cluster.md +++ b/docs/data-sources/cluster.md @@ -35,6 +35,7 @@ data "cockroach_cluster" "cockroach" { - `cockroach_version` (String) Full version of CockroachDB running on the cluster. - `creator_id` (String) ID of the user who created the cluster. - `dedicated` (Attributes) (see [below for nested schema](#nestedatt--dedicated)) +- `delete_protection` (Boolean) Set to true to enable delete protection on the cluster. - `id` (String) The ID of this resource. - `name` (String) Name of the cluster. - `operation_status` (String) Describes the current long-running operation, if any. diff --git a/docs/resources/cluster.md b/docs/resources/cluster.md index 18f481b5..52085852 100644 --- a/docs/resources/cluster.md +++ b/docs/resources/cluster.md @@ -58,6 +58,7 @@ resource "cockroach_cluster" "serverless" { - `cockroach_version` (String) Major version of CockroachDB running on the cluster. - `dedicated` (Attributes) (see [below for nested schema](#nestedatt--dedicated)) +- `delete_protection` (Boolean) Set to true to enable delete protection on the cluster. - `parent_id` (String) The ID of the cluster's parent folder. 'root' is used for a cluster at the root level. - `serverless` (Attributes) (see [below for nested schema](#nestedatt--serverless)) diff --git a/examples/resources/cockroach_cluster/resource.tf b/examples/resources/cockroach_cluster/resource.tf index ea6e45c2..8d80998c 100644 --- a/examples/resources/cockroach_cluster/resource.tf +++ b/examples/resources/cockroach_cluster/resource.tf @@ -11,6 +11,7 @@ resource "cockroach_cluster" "dedicated" { node_count = 1 } ] + delete_protection = true } resource "cockroach_cluster" "serverless" { @@ -24,4 +25,5 @@ resource "cockroach_cluster" "serverless" { name = "us-east1" } ] + delete_protection = false } diff --git a/internal/provider/cluster_data_source.go b/internal/provider/cluster_data_source.go index 2c02524d..aff28a1d 100644 --- a/internal/provider/cluster_data_source.go +++ b/internal/provider/cluster_data_source.go @@ -19,10 +19,11 @@ package provider import ( "context" "fmt" + "net/http" + "github.com/cockroachdb/cockroach-cloud-sdk-go/pkg/client" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" - "net/http" ) type clusterDataSource struct { @@ -165,6 +166,10 @@ func (d *clusterDataSource) Schema( Computed: true, MarkdownDescription: "The ID of the cluster's parent folder. 'root' is used for a cluster at the root level.", }, + "delete_protection": schema.BoolAttribute{ + Computed: true, + Description: "Set to true to enable delete protection on the cluster.", + }, }, } } diff --git a/internal/provider/cluster_resource.go b/internal/provider/cluster_resource.go index 63447d80..e7a824eb 100644 --- a/internal/provider/cluster_resource.go +++ b/internal/provider/cluster_resource.go @@ -247,6 +247,11 @@ func (r *clusterResource) Schema( validators.FolderParentID(), }, }, + "delete_protection": schema.BoolAttribute{ + Computed: true, + Optional: true, + Description: "Set to true to enable delete protection on the cluster. If unset, the server chooses the value on cluster creation, and preserves the value on cluster update.", + }, }, } } @@ -404,6 +409,12 @@ func (r *clusterResource) Create( clusterSpec.SetParentId(parentID) } + deleteProtection := client.DELETEPROTECTIONSTATETYPE_DISABLED + if plan.DeleteProtection.ValueBool() { + deleteProtection = client.DELETEPROTECTIONSTATETYPE_ENABLED + } + clusterSpec.SetDeleteProtection(deleteProtection) + clusterReq := client.NewCreateClusterRequest(plan.Name.ValueString(), client.CloudProviderType(plan.CloudProvider.ValueString()), *clusterSpec) clusterObj, _, err := r.provider.service.CreateCluster(ctx, clusterReq) if err != nil { @@ -513,33 +524,48 @@ func (r *clusterResource) ModifyPlan( var plan *CockroachCluster diags = req.Plan.Get(ctx, &plan) resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() || plan == nil { + if resp.Diagnostics.HasError() { return } - if plan.Name != state.Name { - resp.Diagnostics.AddError("Cannot update cluster name", - "To prevent accidental deletion of data, renaming clusters isn't allowed. "+ - "Please explicitly destroy this cluster before changing its name.") - } - if plan.CloudProvider != state.CloudProvider { - resp.Diagnostics.AddError("Cannot update cluster cloud provider", - "To prevent accidental deletion of data, changing a cluster's cloud provider "+ - "isn't allowed. Please explicitly destroy this cluster before changing its cloud provider.") - } - if ((plan.DedicatedConfig == nil) != (state.DedicatedConfig == nil)) || - ((plan.ServerlessConfig == nil) != (state.ServerlessConfig == nil)) { - resp.Diagnostics.AddError("Cannot update cluster plan type", - "To prevent accidental deletion of data, changing a cluster's plan type "+ - "isn't allowed. Please explicitly destroy this cluster before changing between "+ - "dedicated and serverless plans.") - return + if plan != nil { + if plan.Name != state.Name { + resp.Diagnostics.AddError("Cannot update cluster name", + "To prevent accidental deletion of data, renaming clusters isn't allowed. "+ + "Please explicitly destroy this cluster before changing its name.") + } + if plan.CloudProvider != state.CloudProvider { + resp.Diagnostics.AddError("Cannot update cluster cloud provider", + "To prevent accidental deletion of data, changing a cluster's cloud provider "+ + "isn't allowed. Please explicitly destroy this cluster before changing its cloud provider.") + } + if ((plan.DedicatedConfig == nil) != (state.DedicatedConfig == nil)) || + ((plan.ServerlessConfig == nil) != (state.ServerlessConfig == nil)) { + resp.Diagnostics.AddError("Cannot update cluster plan type", + "To prevent accidental deletion of data, changing a cluster's plan type "+ + "isn't allowed. Please explicitly destroy this cluster before changing between "+ + "dedicated and serverless plans.") + return + } + if dedicated := plan.DedicatedConfig; dedicated != nil && dedicated.PrivateNetworkVisibility != state.DedicatedConfig.PrivateNetworkVisibility { + resp.Diagnostics.AddError("Cannot update network visibility", + "To prevent accidental deletion of data, changing a cluster's network "+ + "visibility isn't allowed. Please explicitly destroy this cluster before changing "+ + "network visibility.") + } } - if dedicated := plan.DedicatedConfig; dedicated != nil && dedicated.PrivateNetworkVisibility != state.DedicatedConfig.PrivateNetworkVisibility { - resp.Diagnostics.AddError("Cannot update network visibility", - "To prevent accidental deletion of data, changing a cluster's network "+ - "visibility isn't allowed. Please explicitly destroy this cluster before changing "+ - "network visibility.") + + if req.Plan.Raw.IsNull() { + // This is a plan to destroy the cluster. We'll check if this cluster + // has delete protection enabled and throw an error here if it does. + // This causes the apply to fail _before_ taking any action, which + // prevents _other_ resources peripheral to the cluster from being + // destroyed as well. + if state.DeleteProtection.ValueBool() { + resp.Diagnostics.AddError("Cannot destroy cluster with delete protection enabled", + "To prevent accidental deletion of data, destroying a cluster with delete protection "+ + "enabled isn't allowed. Please disable delete protection before destroying this cluster.") + } } } @@ -736,6 +762,17 @@ func (r *clusterResource) Update( clusterReq.SetParentId(parentID) } + if !(plan.DeleteProtection.IsNull() || plan.DeleteProtection.IsUnknown()) && + plan.DeleteProtection.ValueBool() != state.DeleteProtection.ValueBool() { + var deleteProtection client.DeleteProtectionStateType + if plan.DeleteProtection.ValueBool() { + deleteProtection = client.DELETEPROTECTIONSTATETYPE_ENABLED + } else { + deleteProtection = client.DELETEPROTECTIONSTATETYPE_DISABLED + } + clusterReq.SetDeleteProtection(deleteProtection) + } + clusterObj, _, err := r.provider.service.UpdateCluster(ctx, state.ID.ValueString(), clusterReq) if err != nil { resp.Diagnostics.AddError( @@ -882,6 +919,13 @@ func loadClusterToTerraformState( state.ParentId = types.StringValue(*clusterObj.ParentId) } + if clusterObj.DeleteProtection != nil && + *clusterObj.DeleteProtection == client.DELETEPROTECTIONSTATETYPE_ENABLED { + state.DeleteProtection = types.BoolValue(true) + } else { + state.DeleteProtection = types.BoolValue(false) + } + if clusterObj.Config.Serverless != nil { serverlessConfig := &ServerlessClusterConfig{ RoutingId: types.StringValue(clusterObj.Config.Serverless.RoutingId), diff --git a/internal/provider/cluster_resource_test.go b/internal/provider/cluster_resource_test.go index 1187a23b..74357d2c 100644 --- a/internal/provider/cluster_resource_test.go +++ b/internal/provider/cluster_resource_test.go @@ -22,6 +22,7 @@ import ( "log" "net/http" "os" + "regexp" "testing" "github.com/cockroachdb/cockroach-cloud-sdk-go/pkg/client" @@ -319,6 +320,7 @@ func serverlessClusterWithSpendLimit(clusterName string) resource.TestStep { resource.TestCheckNoResourceAttr(dataSourceName, "serverless.spend_limit"), resource.TestCheckResourceAttrSet(dataSourceName, "serverless.usage_limits.request_unit_limit"), resource.TestCheckResourceAttrSet(dataSourceName, "serverless.usage_limits.storage_mib_limit"), + resource.TestCheckResourceAttr(dataSourceName, "delete_protection", "false"), ), } } @@ -562,6 +564,7 @@ func multiRegionServerlessClusterResourceRegionUpdate(clusterName string) resour resource.TestCheckNoResourceAttr(dataSourceName, "serverless.spend_limit"), resource.TestCheckResourceAttr(dataSourceName, "serverless.usage_limits.request_unit_limit", "10000000000"), resource.TestCheckResourceAttr(dataSourceName, "serverless.usage_limits.storage_mib_limit", "102400"), + resource.TestCheckResourceAttr(dataSourceName, "delete_protection", "false"), ), } } @@ -618,9 +621,15 @@ func TestIntegrationDedicatedClusterResource(t *testing.T) { finalizedCluster := pendingCluster finalizedCluster.UpgradeStatus = client.CLUSTERUPGRADESTATUSTYPE_FINALIZED - scaledCluster := finalizedCluster + firstUpdateCluster := finalizedCluster + firstUpdateCluster.DeleteProtection = ptr(client.DELETEPROTECTIONSTATETYPE_ENABLED) + + secondUpdateCluster := firstUpdateCluster + secondUpdateCluster.DeleteProtection = ptr(client.DELETEPROTECTIONSTATETYPE_DISABLED) + + scaledCluster := secondUpdateCluster scaledCluster.Config.Dedicated = &client.DedicatedHardwareConfig{} - *scaledCluster.Config.Dedicated = *finalizedCluster.Config.Dedicated + *scaledCluster.Config.Dedicated = *secondUpdateCluster.Config.Dedicated scaledCluster.Config.Dedicated.NumVirtualCpus = 4 httpOk := &http.Response{Status: http.StatusText(http.StatusOK)} @@ -653,7 +662,7 @@ func TestIntegrationDedicatedClusterResource(t *testing.T) { ) s.EXPECT().GetCluster(gomock.Any(), clusterID). - Return(&upgradingCluster, httpOk, nil).Times(1) + Return(&upgradingCluster, httpOk, nil) // Scale (no-op) @@ -664,13 +673,10 @@ func TestIntegrationDedicatedClusterResource(t *testing.T) { }) s.EXPECT().GetCluster(gomock.Any(), clusterID). - Return(&pendingCluster, httpOk, nil).Times(2) + Return(&pendingCluster, httpOk, nil).Times(3) // Finalize - s.EXPECT().GetCluster(gomock.Any(), clusterID). - Return(&pendingCluster, httpOk, nil).Times(1) - s.EXPECT().UpdateCluster(gomock.Any(), clusterID, gomock.Any()). DoAndReturn(func(context.Context, string, *client.UpdateClusterSpecification, ) (*client.Cluster, *http.Response, error) { @@ -678,12 +684,34 @@ func TestIntegrationDedicatedClusterResource(t *testing.T) { }) s.EXPECT().GetCluster(gomock.Any(), clusterID). - Return(&finalizedCluster, httpOk, nil).Times(3) + Return(&finalizedCluster, httpOk, nil).Times(6) // Import state happens here + // First Update + + s.EXPECT().UpdateCluster(gomock.Any(), clusterID, gomock.Any()). + DoAndReturn(func(context.Context, string, *client.UpdateClusterSpecification, + ) (*client.Cluster, *http.Response, error) { + currentCluster := &firstUpdateCluster + return currentCluster, httpOk, nil + }) + + s.EXPECT().GetCluster(gomock.Any(), clusterID). + Return(&firstUpdateCluster, httpOk, nil).Times(7) + + // Failed Delete Attempt + + // Second Update + s.EXPECT().UpdateCluster(gomock.Any(), clusterID, gomock.Any()). + DoAndReturn(func(context.Context, string, *client.UpdateClusterSpecification, + ) (*client.Cluster, *http.Response, error) { + currentCluster := &secondUpdateCluster + return currentCluster, httpOk, nil + }) + s.EXPECT().GetCluster(gomock.Any(), clusterID). - Return(&finalizedCluster, httpOk, nil).Times(3) + Return(&secondUpdateCluster, httpOk, nil).Times(6) // Scale @@ -695,14 +723,14 @@ func TestIntegrationDedicatedClusterResource(t *testing.T) { }) s.EXPECT().GetCluster(gomock.Any(), clusterID). - Return(&scaledCluster, httpOk, nil).Times(5) + Return(&scaledCluster, httpOk, nil).AnyTimes() // Deletion s.EXPECT().DeleteCluster(gomock.Any(), clusterID) scaleStep := resource.TestStep{ - Config: getTestDedicatedClusterResourceConfig(clusterName, latestClusterMajorVersion, false, 4), + Config: getTestDedicatedClusterResourceConfig(clusterName, latestClusterMajorVersion, false, 4, nil), Check: resource.TestCheckResourceAttr("cockroach_cluster.dedicated", "dedicated.num_virtual_cpus", "4"), } @@ -719,7 +747,7 @@ func testDedicatedClusterResource( testSteps := []resource.TestStep{ { - Config: getTestDedicatedClusterResourceConfig(clusterName, minSupportedClusterMajorVersion, false, 2), + Config: getTestDedicatedClusterResourceConfig(clusterName, minSupportedClusterMajorVersion, false, 2, nil), Check: resource.ComposeTestCheckFunc( testCheckCockroachClusterExists(resourceName), resource.TestCheckResourceAttr(resourceName, "name", clusterName), @@ -733,7 +761,7 @@ func testDedicatedClusterResource( ), }, { - Config: getTestDedicatedClusterResourceConfig(clusterName, latestClusterMajorVersion, true, 2), + Config: getTestDedicatedClusterResourceConfig(clusterName, latestClusterMajorVersion, true, 2, nil), Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttr(resourceName, "cockroach_version", latestClusterMajorVersion), resource.TestCheckResourceAttr(dataSourceName, "cockroach_version", latestClusterMajorVersion), @@ -757,6 +785,20 @@ func testDedicatedClusterResource( // turned off due to this reason. ImportStateVerify: false, }, + { + Config: getTestDedicatedClusterResourceConfig(clusterName, latestClusterMajorVersion, false, 2, ptr(true)), + Check: resource.TestCheckResourceAttr(resourceName, "delete_protection", "true"), + }, + { + // Delete step that fails since delete protection is enabled. + Config: " ", + Destroy: true, + ExpectError: regexp.MustCompile(".*Cannot destroy cluster with delete protection enabled*"), + }, + { + Config: getTestDedicatedClusterResourceConfig(clusterName, latestClusterMajorVersion, false, 2, ptr(false)), + Check: resource.TestCheckResourceAttr(resourceName, "delete_protection", "false"), + }, } testSteps = append(testSteps, additionalSteps...) @@ -792,7 +834,14 @@ func testCheckCockroachClusterExists(resourceName string) resource.TestCheckFunc } } -func getTestDedicatedClusterResourceConfig(name, version string, finalize bool, vcpus int) string { +func getTestDedicatedClusterResourceConfig( + name, version string, finalize bool, vcpus int, deleteProtectionEnabled *bool, +) string { + var deleteProtectionConfig string + if deleteProtectionEnabled != nil { + deleteProtectionConfig = fmt.Sprintf("\ndelete_protection = %t\n", *deleteProtectionEnabled) + } + config := fmt.Sprintf(` resource "cockroach_cluster" "dedicated" { name = "%s" @@ -806,12 +855,13 @@ resource "cockroach_cluster" "dedicated" { name: "us-central1" node_count: 1 }] + %s } data "cockroach_cluster" "test" { id = cockroach_cluster.dedicated.id } -`, name, version, vcpus) +`, name, version, vcpus, deleteProtectionConfig) if finalize { config += fmt.Sprintf(` @@ -902,3 +952,7 @@ func TestClusterSchemaInSync(t *testing.T) { dAttrs := dSchema.Schema.Attributes CheckSchemaAttributesMatch(t, rAttrs, dAttrs) } + +func ptr[T any](in T) *T { + return &in +} diff --git a/internal/provider/models.go b/internal/provider/models.go index 36738dd6..07674c33 100644 --- a/internal/provider/models.go +++ b/internal/provider/models.go @@ -88,6 +88,7 @@ type CockroachCluster struct { OperationStatus types.String `tfsdk:"operation_status"` UpgradeStatus types.String `tfsdk:"upgrade_status"` ParentId types.String `tfsdk:"parent_id"` + DeleteProtection types.Bool `tfsdk:"delete_protection"` } type AllowlistEntry struct { diff --git a/vendor/github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault/doc.go b/vendor/github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault/doc.go deleted file mode 100644 index c1ca3989..00000000 --- a/vendor/github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault/doc.go +++ /dev/null @@ -1,5 +0,0 @@ -// Copyright (c) HashiCorp, Inc. -// SPDX-License-Identifier: MPL-2.0 - -// Package stringdefault provides default values for types.String attributes. -package stringdefault diff --git a/vendor/github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault/static_value.go b/vendor/github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault/static_value.go deleted file mode 100644 index deb2965b..00000000 --- a/vendor/github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault/static_value.go +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) HashiCorp, Inc. -// SPDX-License-Identifier: MPL-2.0 - -package stringdefault - -import ( - "context" - "fmt" - - "github.com/hashicorp/terraform-plugin-framework/resource/schema/defaults" - "github.com/hashicorp/terraform-plugin-framework/types" -) - -// StaticString returns a static string value default handler. -// -// Use StaticString if a static default value for a string should be set. -func StaticString(defaultVal string) defaults.String { - return staticStringDefault{ - defaultVal: defaultVal, - } -} - -// staticStringDefault is static value default handler that -// sets a value on a string attribute. -type staticStringDefault struct { - defaultVal string -} - -// Description returns a human-readable description of the default value handler. -func (d staticStringDefault) Description(_ context.Context) string { - return fmt.Sprintf("value defaults to %s", d.defaultVal) -} - -// MarkdownDescription returns a markdown description of the default value handler. -func (d staticStringDefault) MarkdownDescription(_ context.Context) string { - return fmt.Sprintf("value defaults to `%s`", d.defaultVal) -} - -// DefaultString implements the static default value logic. -func (d staticStringDefault) DefaultString(_ context.Context, req defaults.StringRequest, resp *defaults.StringResponse) { - resp.PlanValue = types.StringValue(d.defaultVal) -} diff --git a/vendor/modules.txt b/vendor/modules.txt index e5ea8576..99c227c0 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -211,7 +211,6 @@ github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifie github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier github.com/hashicorp/terraform-plugin-framework/resource/schema/objectplanmodifier github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier -github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier github.com/hashicorp/terraform-plugin-framework/schema/validator github.com/hashicorp/terraform-plugin-framework/tfsdk