diff --git a/mmv1/third_party/terraform/services/sql/resource_sql_database_instance.go.tmpl b/mmv1/third_party/terraform/services/sql/resource_sql_database_instance.go.tmpl index 160f138a7519..867c766c447d 100644 --- a/mmv1/third_party/terraform/services/sql/resource_sql_database_instance.go.tmpl +++ b/mmv1/third_party/terraform/services/sql/resource_sql_database_instance.go.tmpl @@ -6,6 +6,7 @@ import ( "fmt" "log" "reflect" + "slices" "strings" "time" @@ -89,6 +90,7 @@ var ( } replicaConfigurationKeys = []string{ + "replica_configuration.0.cascadable_replica", "replica_configuration.0.ca_certificate", "replica_configuration.0.client_certificate", "replica_configuration.0.client_key", @@ -136,7 +138,10 @@ func ResourceSqlDatabaseInstance() *schema.Resource { CustomizeDiff: customdiff.All( tpgresource.DefaultProviderProject, customdiff.ForceNewIfChange("settings.0.disk_size", compute.IsDiskShrinkage), - customdiff.ForceNewIfChange("master_instance_name", isMasterInstanceNameSet), + customdiff.ForceNewIf("master_instance_name", func(_ context.Context, d *schema.ResourceDiff, meta interface{}) bool { + // If we set master but this is not the new master of a switchover, require replacement and warn user. + return !isSwitchoverFromOldPrimarySide(d) + }), customdiff.IfValueChange("instance_type", isReplicaPromoteRequested, checkPromoteConfigurationsAndUpdateDiff), privateNetworkCustomizeDiff, pitrSupportDbCustomizeDiff, @@ -800,6 +805,13 @@ is set to true. Defaults to ZONAL.`, AtLeastOneOf: replicaConfigurationKeys, Description: `PEM representation of the trusted CA's x509 certificate.`, }, + "cascadable_replica": { + Type: schema.TypeBool, + Optional: true, + ForceNew: false, + AtLeastOneOf: replicaConfigurationKeys, + Description: `Specifies if a SQL Server replica is a cascadable replica. A cascadable replica is a SQL Server cross region replica that supports replica(s) under it.`, + }, "client_certificate": { Type: schema.TypeString, Optional: true, @@ -875,6 +887,15 @@ is set to true. Defaults to ZONAL.`, }, Description: `The configuration for replication.`, }, + "replica_names": { + Type: schema.TypeList, + Computed: true, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + Description: `The replicas of the instance.`, + }, "server_ca_cert": { Type: schema.TypeList, Computed: true, @@ -1317,6 +1338,7 @@ func expandReplicaConfiguration(configured []interface{}) *sqladmin.ReplicaConfi _replicaConfiguration := configured[0].(map[string]interface{}) return &sqladmin.ReplicaConfiguration{ + CascadableReplica: _replicaConfiguration["cascadable_replica"].(bool), FailoverTarget: _replicaConfiguration["failover_target"].(bool), // MysqlReplicaConfiguration has been flattened in the TF schema, so @@ -1642,8 +1664,13 @@ func resourceSqlDatabaseInstanceRead(d *schema.ResourceData, meta interface{}) e } } - if err := d.Set("replica_configuration", flattenReplicaConfiguration(instance.ReplicaConfiguration, d)); err != nil { - log.Printf("[WARN] Failed to set SQL Database Instance Replica Configuration") + if instance.ReplicaConfiguration != nil { + if err := d.Set("replica_configuration", flattenReplicaConfiguration(instance.ReplicaConfiguration, d)); err != nil { + log.Printf("[WARN] Failed to set SQL Database Instance Replica Configuration") + } + } + if err := d.Set("replica_names", instance.ReplicaNames); err != nil { + return fmt.Errorf("Error setting replica_names: %w", err) } ipAddresses := flattenIpAddresses(instance.IpAddresses) if err := d.Set("ip_address", ipAddresses); err != nil { @@ -1699,6 +1726,14 @@ func resourceSqlDatabaseInstanceRead(d *schema.ResourceData, meta interface{}) e return nil } +type replicaDRKind int + +const ( + replicaDRNone replicaDRKind = iota + replicaDRByPromote + replicaDRBySwitchover +) + func resourceSqlDatabaseInstanceUpdate(d *schema.ResourceData, meta interface{}) error { config := meta.(*transport_tpg.Config) userAgent, err := tpgresource.GenerateUserAgentString(d, config.UserAgent) @@ -1715,17 +1750,20 @@ func resourceSqlDatabaseInstanceUpdate(d *schema.ResourceData, meta interface{}) maintenance_version = v.(string) } - promoteReadReplicaRequired := false + replicaDRKind := replicaDRNone if d.HasChange("instance_type") { oldInstanceType, newInstanceType := d.GetChange("instance_type") if isReplicaPromoteRequested(nil, oldInstanceType, newInstanceType, nil) { - err = checkPromoteConfigurations(d) - if err != nil { - return err + if isSwitchoverRequested(d) { + replicaDRKind = replicaDRBySwitchover + } else { + err = checkPromoteConfigurations(d) + if err != nil { + return err + } + replicaDRKind = replicaDRByPromote } - - promoteReadReplicaRequired = true } } @@ -1873,12 +1911,25 @@ func resourceSqlDatabaseInstanceUpdate(d *schema.ResourceData, meta interface{}) } } - if promoteReadReplicaRequired { - err = transport_tpg.Retry(transport_tpg.RetryOptions{ - RetryFunc: func() (rerr error) { + if replicaDRKind != replicaDRNone { + var retryFunc func() (rerr error) + switch replicaDRKind { + case replicaDRByPromote: + retryFunc = func() (rerr error) { op, rerr = config.NewSqlAdminClient(userAgent).Instances.PromoteReplica(project, d.Get("name").(string)).Do() return rerr - }, + } + case replicaDRBySwitchover: + retryFunc = func() (rerr error) { + op, rerr = config.NewSqlAdminClient(userAgent).Instances.Switchover(project, d.Get("name").(string)).Do() + return rerr + } + default: + return fmt.Errorf("unknown replica DR scenario: %v", replicaDRKind) + } + + err = transport_tpg.Retry(transport_tpg.RetryOptions{ + RetryFunc: retryFunc, Timeout: d.Timeout(schema.TimeoutUpdate), ErrorRetryPredicates: []transport_tpg.RetryErrorPredicateFunc{transport_tpg.IsSqlOperationInProgressError}, }) @@ -2339,6 +2390,7 @@ func flattenReplicaConfiguration(replicaConfiguration *sqladmin.ReplicaConfigura if replicaConfiguration != nil { data := map[string]interface{}{ + "cascadable_replica": replicaConfiguration.CascadableReplica, "failover_target": replicaConfiguration.FailoverTarget, // Don't attempt to assign anything from replicaConfiguration.MysqlReplicaConfiguration, @@ -2526,6 +2578,20 @@ func isMasterInstanceNameSet(_ context.Context, oldMasterInstanceName interface{ return true } +func isSwitchoverRequested(d *schema.ResourceData) bool { + originalPrimaryName, _ := d.GetChange("master_instance_name") + _, newReplicaNames := d.GetChange("replica_names") + if !slices.Contains(newReplicaNames.([]interface{}), originalPrimaryName) { + return false + } + dbVersion, _ := d.GetChange("database_version") + if !strings.HasPrefix(dbVersion.(string), "SQLSERVER") { + log.Printf("[WARN] Switchover is only supported for SQL Server %q", dbVersion) + return false + } + return true +} + func isReplicaPromoteRequested(_ context.Context, oldInstanceType interface{}, newInstanceType interface{}, _ interface{}) bool { oldInstanceType = oldInstanceType.(string) newInstanceType = newInstanceType.(string) @@ -2537,6 +2603,30 @@ func isReplicaPromoteRequested(_ context.Context, oldInstanceType interface{}, n return false } +// Check if this resource change is the manual update done on old primary after a switchover. If true, no replacement is needed. +func isSwitchoverFromOldPrimarySide(d *schema.ResourceDiff) bool { + dbVersion, _ := d.GetChange("database_version") + if !strings.HasPrefix(dbVersion.(string), "SQLSERVER") { + log.Printf("[WARN] Switchover is only supported for SQL Server %q", dbVersion) + return false + } + oldInstanceType, newInstanceType := d.GetChange("instance_type") + oldReplicaNames, newReplicaNames := d.GetChange("replica_names") + _, newMasterInstanceName := d.GetChange("master_instance_name") + _, newReplicaConfiguration := d.GetChange("replica_configuration") + if len(newReplicaConfiguration.([]interface{})) != 1 || newReplicaConfiguration.([]interface{})[0] == nil{ + return false; + } + replicaConfiguration := newReplicaConfiguration.([]interface{})[0].(map[string]interface{}) + cascadableReplica, cascadableReplicaFieldExists := replicaConfiguration["cascadable_replica"] + + return newMasterInstanceName != nil && + oldInstanceType.(string) == "CLOUD_SQL_INSTANCE" && newInstanceType.(string) == "READ_REPLICA_INSTANCE" && + slices.Contains(oldReplicaNames.([]interface{}), newMasterInstanceName) && + !slices.Contains(newReplicaNames.([]interface{}), newMasterInstanceName) && + cascadableReplicaFieldExists && cascadableReplica.(bool) +} + func checkPromoteConfigurations(d *schema.ResourceData) error { masterInstanceName := d.GetRawConfig().GetAttr("master_instance_name") replicaConfiguration := d.GetRawConfig().GetAttr("replica_configuration").AsValueSlice() @@ -2574,4 +2664,4 @@ func validatePromoteConfigurations(masterInstanceName cty.Value, replicaConfigur return fmt.Errorf("Replica promote configuration check failed. Please remove replica_configuration and try again.") } return nil -} +} \ No newline at end of file diff --git a/mmv1/third_party/terraform/services/sql/resource_sql_database_instance_test.go b/mmv1/third_party/terraform/services/sql/resource_sql_database_instance_test.go index ee76c51dd43d..22f76edd78a8 100644 --- a/mmv1/third_party/terraform/services/sql/resource_sql_database_instance_test.go +++ b/mmv1/third_party/terraform/services/sql/resource_sql_database_instance_test.go @@ -19,6 +19,9 @@ import ( // Fields that should be ignored in import tests because they aren't returned // from GCP (and thus can't be imported) var ignoredReplicaConfigurationFields = []string{ + "deletion_protection", + "root_password", + "replica_configuration.0.cascadable_replica", "replica_configuration.0.ca_certificate", "replica_configuration.0.client_certificate", "replica_configuration.0.client_key", @@ -29,7 +32,7 @@ var ignoredReplicaConfigurationFields = []string{ "replica_configuration.0.ssl_cipher", "replica_configuration.0.username", "replica_configuration.0.verify_server_certificate", - "deletion_protection", + "replica_configuration.0.failover_target", } func TestAccSqlDatabaseInstance_basicInferredName(t *testing.T) { @@ -2396,6 +2399,71 @@ func TestAccSqlDatabaseInstance_ReplicaPromoteSkippedWithNoMasterInstanceNameAnd }) } +// Switchover between primary and cascadable replica sunny case +func TestAccSqlDatabaseInstance_SwitchoverSuccess(t *testing.T) { + t.Parallel() + primaryName := "tf-test-sql-instance-" + acctest.RandString(t, 10) + replicaName := "tf-test-sql-instance-replica" + acctest.RandString(t, 10) + acctest.VcrTest(t, resource.TestCase{ + PreCheck: func() { acctest.AccTestPreCheck(t) }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories(t), + CheckDestroy: testAccSqlDatabaseInstanceDestroyProducer(t), + Steps: []resource.TestStep{ + { + Config: testGoogleSqlDatabaseInstanceConfig_SqlServerwithCascadableReplica(primaryName, replicaName), + // TODO: remove when API change is rolled out. + ExpectNonEmptyPlan: true, + }, + { + ResourceName: "google_sql_database_instance.original-primary", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: ignoredReplicaConfigurationFields, + }, + { + ResourceName: "google_sql_database_instance.original-replica", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: ignoredReplicaConfigurationFields, + }, + { + Config: googleSqlDatabaseInstance_switchover(primaryName, replicaName), + // TODO: remove when API change is rolled out. + ExpectNonEmptyPlan: true, + }, + { + RefreshState: true, + // TODO: remove when API change is rolled out. + ExpectNonEmptyPlan: true, + Check: resource.ComposeTestCheckFunc(resource.TestCheckTypeSetElemAttr("google_sql_database_instance.original-replica", "replica_names.*", primaryName), checkSwitchoverOriginalReplicaConfigurations("google_sql_database_instance.original-replica"), checkSwitchoverOriginalPrimaryConfigurations("google_sql_database_instance.original-primary", replicaName)), + }, + { + ResourceName: "google_sql_database_instance.original-primary", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: ignoredReplicaConfigurationFields, + }, + { + ResourceName: "google_sql_database_instance.original-replica", + ImportState: true, + ImportStateVerify: true, + // original-replica is no longer a replica, but replica_configuration is O + C and cannot be unset + ImportStateVerifyIgnore: []string{"replica_configuration", "deletion_protection", "root_password"}, + }, + { + // Delete replica first so PostTestDestroy doesn't fail when deleting instances which have replicas. We've already validated switchover behavior, the remaining steps are cleanup + Config: googleSqlDatabaseInstance_deleteReplicasAfterSwitchover(primaryName, replicaName), + // We delete replica, but haven't updated the master's replica_names + ExpectNonEmptyPlan: true, + }, + { + // Remove replica from primary's resource + Config: googleSqlDatabaseInstance_removeReplicaFromPrimaryAfterSwitchover(replicaName), + }, + }, + }) +} + func TestAccSqlDatabaseInstance_updateSslOptionsForPostgreSQL(t *testing.T) { t.Parallel() @@ -3077,6 +3145,118 @@ resource "google_sql_database_instance" "instance-failover" { `, instanceName, failoverName) } +// Create SQL server primary with cascadable replica +func testGoogleSqlDatabaseInstanceConfig_SqlServerwithCascadableReplica(primaryName string, replicaName string) string { + return fmt.Sprintf(` +resource "google_sql_database_instance" "original-primary" { + name = "%s" + region = "us-central1" + database_version = "SQLSERVER_2019_ENTERPRISE" + deletion_protection = false + + root_password = "sqlserver1" + settings { + tier = "db-perf-optimized-N-2" + edition = "ENTERPRISE_PLUS" + } +} + +resource "google_sql_database_instance" "original-replica" { + name = "%s" + region = "us-east1" + database_version = "SQLSERVER_2019_ENTERPRISE" + master_instance_name = google_sql_database_instance.original-primary.name + deletion_protection = false + root_password = "sqlserver1" + replica_configuration { + cascadable_replica = true + } + + settings { + tier = "db-perf-optimized-N-2" + edition = "ENTERPRISE_PLUS" + } +} + +`, primaryName, replicaName) +} + +func googleSqlDatabaseInstance_switchover(primaryName string, replicaName string) string { + return fmt.Sprintf(` +resource "google_sql_database_instance" "original-primary" { + name = "%s" + region = "us-central1" + database_version = "SQLSERVER_2019_ENTERPRISE" + deletion_protection = false + root_password = "sqlserver1" + instance_type = "READ_REPLICA_INSTANCE" + master_instance_name = "%s" + replica_configuration { + cascadable_replica = true + } + replica_names = [] + settings { + tier = "db-perf-optimized-N-2" + edition = "ENTERPRISE_PLUS" + } +} + +resource "google_sql_database_instance" "original-replica" { + name = "%s" + region = "us-east1" + database_version = "SQLSERVER_2019_ENTERPRISE" + deletion_protection = false + root_password = "sqlserver1" + instance_type = "CLOUD_SQL_INSTANCE" + replica_names = [google_sql_database_instance.original-primary.name] + settings { + tier = "db-perf-optimized-N-2" + edition = "ENTERPRISE_PLUS" + } +} + +`, primaryName, replicaName, replicaName) +} + +// After a switchover, the original-primary is now the replica and must be removed first. +func googleSqlDatabaseInstance_deleteReplicasAfterSwitchover(primaryName, replicaName string) string { + return fmt.Sprintf(` +resource "google_sql_database_instance" "original-replica" { + name = "%s" + region = "us-east1" + database_version = "SQLSERVER_2019_ENTERPRISE" + deletion_protection = false + root_password = "sqlserver1" + instance_type = "CLOUD_SQL_INSTANCE" + replica_names = ["%s"] + settings { + tier = "db-perf-optimized-N-2" + edition = "ENTERPRISE_PLUS" + } +} + +`, replicaName, primaryName) +} + +// Update original-replica replica_names after deleting original-primary +func googleSqlDatabaseInstance_removeReplicaFromPrimaryAfterSwitchover(replicaName string) string { + return fmt.Sprintf(` +resource "google_sql_database_instance" "original-replica" { + name = "%s" + region = "us-east1" + database_version = "SQLSERVER_2019_ENTERPRISE" + deletion_protection = false + root_password = "sqlserver1" + instance_type = "CLOUD_SQL_INSTANCE" + replica_names = [] + settings { + tier = "db-perf-optimized-N-2" + edition = "ENTERPRISE_PLUS" + } +} +`, replicaName) +} + func testAccSqlDatabaseInstance_basicInstanceForPsc(instanceName string, projectId string, orgId string, billingAccount string) string { return fmt.Sprintf(` resource "google_project" "testproject" { @@ -4302,7 +4482,58 @@ func checkPromoteReplicaConfigurations(resourceName string) func(*terraform.Stat if ok && replicaConfiguration != "" { return fmt.Errorf("Error in replica promotion. replica_configuration should not be present in %s state.", resourceName) } + return nil + } +} + +// Check that original-replica is now the primary +func checkSwitchoverOriginalReplicaConfigurations(replicaResourceName string) func(*terraform.State) error { + return func(s *terraform.State) error { + replicaResource, ok := s.RootModule().Resources[replicaResourceName] + if !ok { + return fmt.Errorf("Can't find %s in state", replicaResourceName) + } + replicaResourceAttributes := replicaResource.Primary.Attributes + + replicaInstanceType, ok := replicaResourceAttributes["instance_type"] + if !ok { + return fmt.Errorf("Instance type is not present in state for %s", replicaResourceName) + } + if replicaInstanceType != "CLOUD_SQL_INSTANCE" { + return fmt.Errorf("Error in switchover. Original replica instance_type is %s, it should be CLOUD_SQL_INSTANCE.", replicaInstanceType) + } + + replicaMasterInstanceName, ok := replicaResourceAttributes["master_instance_name"] + if ok && replicaMasterInstanceName != "" { + return fmt.Errorf("Error in switchover. master_instance_name should not be set on new primary") + } + return nil + } +} + +// Check that original-primary is now a replica +func checkSwitchoverOriginalPrimaryConfigurations(primaryResourceName string, replicaName string) func(*terraform.State) error { + return func(s *terraform.State) error { + primaryResource, ok := s.RootModule().Resources[primaryResourceName] + if !ok { + return fmt.Errorf("Can't find %s in state", primaryResourceName) + } + primaryResourceAttributes := primaryResource.Primary.Attributes + primaryInstanceType, ok := primaryResourceAttributes["instance_type"] + if !ok { + return fmt.Errorf("Instance type is not present in state for %s", primaryResourceName) + } + if primaryInstanceType != "READ_REPLICA_INSTANCE" { + return fmt.Errorf("Error in switchover. Original primary instance_type is %s, it should be READ_REPLICA_INSTANCE.", primaryInstanceType) + } + primaryMasterInstanceName, ok := primaryResourceAttributes["master_instance_name"] + if !ok { + return fmt.Errorf("Master instance name is not present in state for %s", primaryResourceName) + } + if ok && primaryMasterInstanceName != replicaName { + return fmt.Errorf("Error in switchover. Master_instance_name should be %s", replicaName) + } return nil } } diff --git a/mmv1/third_party/terraform/website/docs/r/sql_database_instance.html.markdown b/mmv1/third_party/terraform/website/docs/r/sql_database_instance.html.markdown index dfc63b30ecec..ea59a207f5fb 100644 --- a/mmv1/third_party/terraform/website/docs/r/sql_database_instance.html.markdown +++ b/mmv1/third_party/terraform/website/docs/r/sql_database_instance.html.markdown @@ -234,7 +234,9 @@ includes an up-to-date reference of supported versions. is not provided, the provider project is used. * `replica_configuration` - (Optional) The configuration for replication. The - configuration is detailed below. Valid only for MySQL instances. + configuration is detailed below. + +* `replica_names` - (Optional, Computed) List of replica names. Can be updated. * `root_password` - (Optional) Initial root password. Can be updated. Required for MS SQL Server. @@ -451,6 +453,10 @@ The optional `settings.password_validation_policy` subblock for instances declar The optional `replica_configuration` block must have `master_instance_name` set to work, cannot be updated, and supports: +* `cascadable_replica` - (Optional) Specifies if the replica is a cascadable replica. If true, instance must be in different region from primary. + + ~> **NOTE:** Only supported for SQL Server database. + * `ca_certificate` - (Optional) PEM representation of the trusted CA's x509 certificate. @@ -470,7 +476,8 @@ to work, cannot be updated, and supports: If the field is set to true the replica will be designated as a failover replica. If the master instance fails, the replica instance will be promoted as the new master instance. - ~> **NOTE:** Not supported for Postgres database. + + ~> **NOTE:** Only supported for MySQL. * `master_heartbeat_period` - (Optional) Time in ms between replication heartbeats. @@ -572,6 +579,36 @@ performing filtering in a Terraform config. * `server_ca_cert.0.sha1_fingerprint` - SHA Fingerprint of the CA Cert. +## Switchover (SQL Server Only) +Users can perform a switchover on any direct `cascadable` replica by following the steps below. + + ~>**WARNING:** Failure to follow these steps can lead to data loss (You will be warned during plan stage). To prevent data loss during a switchover, please verify your plan with the checklist below. + +### Steps to Invoke Switchover + +Create a `cascadable` replica in a different region from the primary (`cascadable_replica` is set to true in `replica_configuration`) + +#### Invoking switchover in the replica resource: +1. Change instance_type from `READ_REPLICA_INSTANCE` to `CLOUD_SQL_INSTANCE` +2. Remove `master_instance_name` +3. Remove `replica_configuration` +4. Add current primary's name to the replica's `replica_names` list + +#### Updating the primary resource: +1. Change `instance_type` from `CLOUD_SQL_INSTANCE` to `READ_REPLICA_INSTANCE` +2. Set `master_instance_name` to the original replica (which will be primary after switchover) +3. Set `replica_configuration` and set `cascadable_replica` to `true` +4. Remove original replica from `replica_names` + + ~> **NOTE**: Do **not** delete the replica_names field, even if it has no replicas remaining. Set replica_names = [ ] to indicate it having no replicas. + +#### Plan and verify that: +- `terraform plan` outputs **"0 to add, 0 to destroy"** +- `terraform plan` does not say **"must be replaced"** for any resource +- Every resource **"will be updated in-place"** +- Only the 2 instances involved in switchover have planned changes +- (Optional) Use `deletion_protection` on instances as a safety measure + ## Timeouts `google_sql_database_instance` provides the following @@ -609,4 +646,4 @@ $ terraform import google_sql_database_instance.default {{name}} ~> **NOTE:** Some fields (such as `replica_configuration`) won't show a diff if they are unset in config and set on the server. When importing, double-check that your config has all the fields set that you expect- just seeing -no diff isn't sufficient to know that your config could reproduce the imported resource. +no diff isn't sufficient to know that your config could reproduce the imported resource. \ No newline at end of file