diff --git a/examples/mysql-replicas/main.tf b/examples/mysql-replicas/main.tf index 6876169..a7c0a2e 100644 --- a/examples/mysql-replicas/main.tf +++ b/examples/mysql-replicas/main.tf @@ -68,6 +68,10 @@ module "mysql" { enable_failover_replica = true failover_replica_zone = "${var.failover_replica_zone}" + # Indicate we want read replicas to be created + num_read_replicas = "${var.num_read_replicas}" + read_replica_zones = ["${var.read_replica_zones}"] + # These together will construct the master_user privileges, i.e. # 'master_user_name'@'master_user_host' IDENTIFIED BY 'master_user_password'. # These should typically be set as the environment variable TF_VAR_master_user_password, etc. diff --git a/examples/mysql-replicas/outputs.tf b/examples/mysql-replicas/outputs.tf index 5d067c1..26d4ff7 100644 --- a/examples/mysql-replicas/outputs.tf +++ b/examples/mysql-replicas/outputs.tf @@ -64,3 +64,27 @@ output "failover_proxy_connection" { description = "Failover instance path for connecting with Cloud SQL Proxy. Read more at https://cloud.google.com/sql/docs/mysql/sql-proxy" value = "${module.mysql.failover_proxy_connection}" } + +# ------------------------------------------------------------------------------ +# READ REPLICA OUTPUTS +# ------------------------------------------------------------------------------ + +output "read_replica_instance_names" { + description = "List of names for the read replica instances" + value = "${module.mysql.read_replica_instance_names}" +} + +output "read_replica_public_ips" { + description = "List of first IPv4 addresses of the addresses assigned to the read replica instances. As the instances have only public IP in the example, the are the public IP addresses." + value = "${module.mysql.read_replica_first_ip_addresses}" +} + +output "read_replica_instances" { + description = "List of self links to the read replica instances" + value = "${module.mysql.read_replica_instances}" +} + +output "read_replica_proxy_connections" { + description = "List of read replica instance paths for connecting with Cloud SQL Proxy. Read more at https://cloud.google.com/sql/docs/mysql/sql-proxy" + value = "${module.mysql.read_replica_proxy_connections}" +} diff --git a/examples/mysql-replicas/variables.tf b/examples/mysql-replicas/variables.tf index d46c8ee..6fc3399 100644 --- a/examples/mysql-replicas/variables.tf +++ b/examples/mysql-replicas/variables.tf @@ -19,6 +19,18 @@ variable "failover_replica_zone" { description = "The preferred zone for the failover instance (e.g. 'us-central1-b'). Must be different than 'master_zone'." } +variable "num_read_replicas" { + description = "The number of read replicas to create. Cloud SQL will replicate all data from the master to these replicas, which you can use to horizontally scale read traffic." +} + +variable "read_replica_zones" { + description = "A list of compute zones where read replicas should be created. List size should match 'num_read_replicas'" + type = "list" + + # Example: + # default = ["us-central1-b", "us-central1-c"] +} + # Note, after a name db instance is used, it cannot be reused for up to one week. variable "name_prefix" { description = "The name prefix for the database instance. Will be appended with a random string. Use lowercase letters, numbers, and hyphens. Start with a letter." diff --git a/modules/mysql/compute_outputs.tf b/modules/mysql/compute_outputs.tf index 55d4e90..ebba7bc 100644 --- a/modules/mysql/compute_outputs.tf +++ b/modules/mysql/compute_outputs.tf @@ -59,3 +59,12 @@ data "template_file" "failover_certificate_sha1_fingerprint" { count = "${var.enable_failover_replica}" template = "${google_sql_database_instance.failover_replica.0.server_ca_cert.0.sha1_fingerprint}" } + +# ------------------------------------------------------------------------------ +# READ REPLICA PROXY CONNECTION TEMPLATE +# ------------------------------------------------------------------------------ + +data "template_file" "read_replica_proxy_connection" { + count = "${var.num_read_replicas}" + template = "${var.project}:${var.region}:${google_sql_database_instance.read_replica.*.name[count.index]}" +} diff --git a/modules/mysql/main.tf b/modules/mysql/main.tf index cbe4620..f8a5aae 100644 --- a/modules/mysql/main.tf +++ b/modules/mysql/main.tf @@ -187,10 +187,62 @@ resource "google_sql_database_instance" "failover_replica" { } } +# ------------------------------------------------------------------------------ +# CREATE THE READ REPLICAS +# ------------------------------------------------------------------------------ + +resource "google_sql_database_instance" "read_replica" { + count = "${var.num_read_replicas}" + + depends_on = ["google_sql_database_instance.failover_replica"] + + provider = "google-beta" + name = "${var.name}-read-${count.index}" + project = "${var.project}" + region = "${var.region}" + database_version = "${var.engine}" + + # The name of the instance that will act as the master in the replication setup. + master_instance_name = "${google_sql_database_instance.master.name}" + + replica_configuration { + # Specifies that the replica is not the failover target. + failover_target = false + } + + settings { + tier = "${var.machine_type}" + authorized_gae_applications = ["${var.authorized_gae_applications}"] + disk_autoresize = "${var.disk_autoresize}" + + ip_configuration = ["${local.ip_configuration}"] + + location_preference { + follow_gae_application = "${var.follow_gae_application}" + zone = "${element(var.read_replica_zones, count.index)}" + } + + disk_size = "${var.disk_size}" + disk_type = "${var.disk_type}" + database_flags = ["${var.database_flags}"] + + user_labels = "${var.custom_labels}" + } + + # Default timeouts are 10 minutes, which in most cases should be enough. + # Sometimes the database creation can, however, take longer, so we + # increase the timeouts slightly. + timeouts { + create = "30m" + delete = "30m" + update = "30m" + } +} + # ------------------------------------------------------------------------------ # CREATE A TEMPLATE FILE TO SIGNAL ALL RESOURCES HAVE BEEN CREATED # ------------------------------------------------------------------------------ data "template_file" "complete" { - depends_on = ["google_sql_database_instance.failover_replica"] + depends_on = ["google_sql_database_instance.read_replica"] template = "true" } diff --git a/modules/mysql/outputs.tf b/modules/mysql/outputs.tf index 2d74e43..23c592b 100644 --- a/modules/mysql/outputs.tf +++ b/modules/mysql/outputs.tf @@ -129,6 +129,65 @@ output "failover_replica_ca_cert_sha1_fingerprint" { description = "SHA Fingerprint of the failover instance CA Cert" } +# ------------------------------------------------------------------------------ +# READ REPLICA OUTPUTS +# ------------------------------------------------------------------------------ + +output "read_replica_instance_names" { + description = "List of names for the read replica instances" + value = ["${google_sql_database_instance.read_replica.*.name}"] +} + +# Due to the provider output format (list of list of maps), this will be rendered in a very awkward way and as such is really not usable +output "read_replica_ip_addresses" { + description = "List of IP addresses of the read replica instances as a list of maps, see https://www.terraform.io/docs/providers/google/r/sql_database_instance.html#ip_address-0-ip_address" + value = ["${google_sql_database_instance.read_replica.*.ip_address}"] +} + +output "read_replica_first_ip_addresses" { + description = "List of first IPv4 addresses of the addresses assigned to the read replica instances. If the instance has only public IP, it is the public IP address. If it has only private IP, it the private IP address. If it has both, it is the first item in the list and full IP address details are in 'instance_ip_addresses'" + value = ["${google_sql_database_instance.read_replica.*.first_ip_address}"] +} + +output "read_replica_instances" { + description = "List of self links to the read replica instances" + value = ["${google_sql_database_instance.read_replica.*.self_link}"] +} + +output "read_replica_proxy_connections" { + description = "List of read replica instance paths for connecting with Cloud SQL Proxy. Read more at https://cloud.google.com/sql/docs/mysql/sql-proxy" + value = ["${data.template_file.read_replica_proxy_connection.*.rendered}"] +} + +# ------------------------------------------------------------------------------ +# READ REPLICA CERT OUTPUTS +# ------------------------------------------------------------------------------ + +output "read_replica_ca_certs" { + value = "${local.failover_certificate}" + description = "List of CA Certificates used to connect to the read replica instances via SSL" +} + +output "read_replica_ca_cert_common_names" { + value = "${local.failover_certificate_common_name}" + description = "List of CNs valid for the read replica instances CA Certs" +} + +output "read_replica_ca_cert_create_times" { + value = "${local.failover_certificate_create_time}" + description = "List of creation times of the read replica instances CA Certs" +} + +output "read_replica_ca_cert_expiration_times" { + value = "${local.failover_certificate_expiration_time}" + description = "List of expiration times of the read replica instances CA Certs" +} + +output "read_replica_ca_cert_sha1_fingerprints" { + value = "${local.failover_certificate_sha1_fingerprint}" + description = "List of SHA Fingerprints of the read replica instances CA Certs" +} + # ------------------------------------------------------------------------------ # MISC OUTPUTS # ------------------------------------------------------------------------------ diff --git a/modules/mysql/variables.tf b/modules/mysql/variables.tf index e458eed..7082c50 100644 --- a/modules/mysql/variables.tf +++ b/modules/mysql/variables.tf @@ -186,6 +186,20 @@ variable "private_network" { default = "" } +variable "num_read_replicas" { + description = "The number of read replicas to create. Cloud SQL will replicate all data from the master to these replicas, which you can use to horizontally scale read traffic." + default = 0 +} + +variable "read_replica_zones" { + description = "A list of compute zones where read replicas should be created. List size should match 'num_read_replicas'" + type = "list" + default = [] + + # Example: + # default = ["us-central1-b", "us-central1-c"] +} + variable "custom_labels" { description = "A map of custom labels to apply to the instance. The key is the label name and the value is the label value." type = "map" diff --git a/test/example_mysql_private_ip_test.go b/test/example_mysql_private_ip_test.go index b9364ee..09a6ea9 100644 --- a/test/example_mysql_private_ip_test.go +++ b/test/example_mysql_private_ip_test.go @@ -42,7 +42,7 @@ func TestMySqlPrivateIP(t *testing.T) { test_structure.RunTestStage(t, "deploy", func() { region := test_structure.LoadString(t, exampleDir, KEY_REGION) projectId := test_structure.LoadString(t, exampleDir, KEY_PROJECT) - terraformOptions := createTerratestOptionsForMySql(projectId, region, exampleDir, NAME_PREFIX_PRIVATE, "", "") + terraformOptions := createTerratestOptionsForMySql(projectId, region, exampleDir, NAME_PREFIX_PRIVATE, "", "", 0, "") test_structure.SaveTerraformOptions(t, exampleDir, terraformOptions) terraform.InitAndApply(t, terraformOptions) diff --git a/test/example_mysql_public_ip_test.go b/test/example_mysql_public_ip_test.go index 6691ca3..582c538 100644 --- a/test/example_mysql_public_ip_test.go +++ b/test/example_mysql_public_ip_test.go @@ -65,7 +65,7 @@ func TestMySqlPublicIP(t *testing.T) { test_structure.RunTestStage(t, "deploy", func() { region := test_structure.LoadString(t, exampleDir, KEY_REGION) projectId := test_structure.LoadString(t, exampleDir, KEY_PROJECT) - terraformOptions := createTerratestOptionsForMySql(projectId, region, exampleDir, NAME_PREFIX_PUBLIC, "", "") + terraformOptions := createTerratestOptionsForMySql(projectId, region, exampleDir, NAME_PREFIX_PUBLIC, "", "", 0, "") test_structure.SaveTerraformOptions(t, exampleDir, terraformOptions) terraform.InitAndApply(t, terraformOptions) diff --git a/test/example_mysql_replicas_test.go b/test/example_mysql_replicas_test.go index cd3ee5b..dbe1c27 100644 --- a/test/example_mysql_replicas_test.go +++ b/test/example_mysql_replicas_test.go @@ -24,6 +24,7 @@ func TestMySqlReplicas(t *testing.T) { //os.Setenv("SKIP_deploy", "true") //os.Setenv("SKIP_validate_outputs", "true") //os.Setenv("SKIP_sql_tests", "true") + //os.Setenv("SKIP_read_replica_tests", "true") //os.Setenv("SKIP_teardown", "true") _examplesDir := test_structure.CopyTerraformFolderToTemp(t, "../", "examples") @@ -34,11 +35,13 @@ func TestMySqlReplicas(t *testing.T) { projectId := gcp.GetGoogleProjectIDFromEnvVar(t) region := getRandomRegion(t, projectId) - masterZone, replicaZone := getTwoDistinctRandomZonesForRegion(t, projectId, region) + masterZone, failoverReplicaZone := getTwoDistinctRandomZonesForRegion(t, projectId, region) + readReplicaZone := gcp.GetRandomZoneForRegion(t, projectId, region) test_structure.SaveString(t, exampleDir, KEY_REGION, region) test_structure.SaveString(t, exampleDir, KEY_MASTER_ZONE, masterZone) - test_structure.SaveString(t, exampleDir, KEY_REPLICA_ZONE, replicaZone) + test_structure.SaveString(t, exampleDir, KEY_FAILOVER_REPLICA_ZONE, failoverReplicaZone) + test_structure.SaveString(t, exampleDir, KEY_READ_REPLICA_ZONE, readReplicaZone) test_structure.SaveString(t, exampleDir, KEY_PROJECT, projectId) }) @@ -53,8 +56,9 @@ func TestMySqlReplicas(t *testing.T) { region := test_structure.LoadString(t, exampleDir, KEY_REGION) projectId := test_structure.LoadString(t, exampleDir, KEY_PROJECT) masterZone := test_structure.LoadString(t, exampleDir, KEY_MASTER_ZONE) - replicaZone := test_structure.LoadString(t, exampleDir, KEY_REPLICA_ZONE) - terraformOptions := createTerratestOptionsForMySql(projectId, region, exampleDir, NAME_PREFIX_REPLICAS, masterZone, replicaZone) + failoverReplicaZone := test_structure.LoadString(t, exampleDir, KEY_FAILOVER_REPLICA_ZONE) + readReplicaZone := test_structure.LoadString(t, exampleDir, KEY_READ_REPLICA_ZONE) + terraformOptions := createTerratestOptionsForMySql(projectId, region, exampleDir, NAME_PREFIX_REPLICAS, masterZone, failoverReplicaZone, 1, readReplicaZone) test_structure.SaveTerraformOptions(t, exampleDir, terraformOptions) terraform.InitAndApply(t, terraformOptions) @@ -137,4 +141,55 @@ func TestMySqlReplicas(t *testing.T) { // Since we set the auto increment to 7, modulus should always be 0 assert.Equal(t, int64(0), int64(lastId%7)) }) + + // TEST REGULAR SQL CLIENT + test_structure.RunTestStage(t, "read_replica_tests", func() { + terraformOptions := test_structure.LoadTerraformOptions(t, exampleDir) + + readReplicaPublicIpList := terraform.OutputList(t, terraformOptions, OUTPUT_READ_REPLICA_PUBLIC_IPS) + readReplicaPublicIp := readReplicaPublicIpList[0] + + connectionString := fmt.Sprintf("%s:%s@tcp(%s:3306)/%s", DB_USER, DB_PASS, readReplicaPublicIp, DB_NAME) + + // Does not actually open up the connection - just returns a DB ref + logger.Logf(t, "Connecting to read replica: %s", readReplicaPublicIp) + db, err := sql.Open("mysql", connectionString) + require.NoError(t, err, "Failed to open DB connection to read replica") + + // Make sure we clean up properly + defer db.Close() + + // Run ping to actually test the connection + logger.Log(t, "Ping the read replica DB") + if err = db.Ping(); err != nil { + t.Fatalf("Failed to ping read replica DB: %v", err) + } + + // Try to insert data to verify we cannot write + logger.Logf(t, "Insert data: %s", MYSQL_INSERT_TEST_ROW) + stmt, err := db.Prepare(MYSQL_INSERT_TEST_ROW) + require.NoError(t, err, "Failed to prepare insert readonly statement") + + // Execute the statement + _, err = stmt.Exec("ReadOnlyGrunt") + // This time we actually expect an error: + // 'The MySQL server is running with the --read-only option so it cannot execute this statement' + require.Error(t, err, "Should not be able to write to read replica") + logger.Logf(t, "Failed to insert data to read replica as expected: %v", err) + + // Prepare statement for reading data + stmtOut, err := db.Prepare(MYSQL_QUERY_ROW_COUNT) + require.NoError(t, err, "Failed to prepare readonly count statement") + + // Query data, results don't matter... + logger.Logf(t, "Query r/o data: %s", MYSQL_QUERY_ROW_COUNT) + + var numResults int + + err = stmtOut.QueryRow().Scan(&numResults) + require.NoError(t, err, "Failed to execute query statement on read replica") + + logger.Logf(t, "Number of rows... just for fun: %v", numResults) + + }) } diff --git a/test/test_util.go b/test/test_util.go index 4adfd3f..2d3ff6d 100644 --- a/test/test_util.go +++ b/test/test_util.go @@ -13,7 +13,8 @@ const DB_PASS = "testpassword" const KEY_REGION = "region" const KEY_PROJECT = "project" const KEY_MASTER_ZONE = "masterZone" -const KEY_REPLICA_ZONE = "replicaZone" +const KEY_FAILOVER_REPLICA_ZONE = "failoverReplicaZone" +const KEY_READ_REPLICA_ZONE = "readReplicaZone" const MYSQL_VERSION = "MYSQL_5_7" @@ -22,6 +23,9 @@ const OUTPUT_MASTER_INSTANCE_NAME = "master_instance_name" const OUTPUT_FAILOVER_INSTANCE_NAME = "failover_instance_name" const OUTPUT_MASTER_PROXY_CONNECTION = "master_proxy_connection" const OUTPUT_FAILOVER_PROXY_CONNECTION = "failover_proxy_connection" +const OUTPUT_READ_REPLICA_PROXY_CONNECTIONS = "read_replica_proxy_connections" +const OUTPUT_READ_REPLICA_INSTANCE_NAMES = "read_replica_instance_names" +const OUTPUT_READ_REPLICA_PUBLIC_IPS = "read_replica_public_ips" const OUTPUT_MASTER_PUBLIC_IP = "master_public_ip" const OUTPUT_MASTER_PRIVATE_IP = "master_private_ip" const OUTPUT_MASTER_CA_CERT = "master_ca_cert" @@ -33,6 +37,7 @@ const OUTPUT_DB_NAME = "db_name" const MYSQL_CREATE_TEST_TABLE_WITH_AUTO_INCREMENT_STATEMENT = "CREATE TABLE IF NOT EXISTS test (id int NOT NULL AUTO_INCREMENT, name varchar(10) NOT NULL, PRIMARY KEY (ID))" const MYSQL_EMPTY_TEST_TABLE_STATEMENT = "DELETE FROM test" const MYSQL_INSERT_TEST_ROW = "INSERT INTO test(name) VALUES(?)" +const MYSQL_QUERY_ROW_COUNT = "SELECT count(*) FROM test" func getRandomRegion(t *testing.T, projectID string) string { approvedRegions := []string{"europe-north1", "europe-west1", "europe-west2", "europe-west3", "us-central1", "us-east1", "us-west1"} @@ -53,7 +58,7 @@ func getTwoDistinctRandomZonesForRegion(t *testing.T, projectID string, region s return firstZone, secondZone } -func createTerratestOptionsForMySql(projectId string, region string, exampleDir string, namePrefix string, masterZone string, replicaZone string) *terraform.Options { +func createTerratestOptionsForMySql(projectId string, region string, exampleDir string, namePrefix string, masterZone string, failoverReplicaZone string, numReadReplicas int, readReplicaZone string) *terraform.Options { terratestOptions := &terraform.Options{ // The path to where your Terraform code is located @@ -61,7 +66,9 @@ func createTerratestOptionsForMySql(projectId string, region string, exampleDir Vars: map[string]interface{}{ "region": region, "master_zone": masterZone, - "failover_replica_zone": replicaZone, + "num_read_replicas": numReadReplicas, + "read_replica_zones": []string{readReplicaZone}, + "failover_replica_zone": failoverReplicaZone, "project": projectId, "name_prefix": namePrefix, "mysql_version": MYSQL_VERSION,