Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ This module creates database and users on an existing CloudSQL instance. The str

To enforce permissions, the module executes SQL commands with the mysql cli, which is therefore a prerequisite (it must be present in the filesystem where terraform apply is executed).

For MySQL 8.x instances, the module automatically removes the default `cloudsqlsuperuser` role, clears any global privileges and assigns the target database as the only default role so that new users are scoped exclusively to their database.

If you ever need to rerun all local scripts (start proxy → grant privileges → stop proxy) without recreating the module-managed users, set a different value for the `permissions_refresh_id` variable (use the `YYYYMMDD` format, e.g. `20251110`) and run `terraform apply`; changing the value forces Terraform to recreate the null resources that execute those scripts while keeping the `google_sql_user` resources in place (see `examples/main.tf` for a ready-to-use snippet).

In addition, the script must be able to connect to the CloudSQL instance. In case this is not easily accessible from the terraform cli, the module is able to:

1. Start an instance of [CloudSQL Auth Proxy](https://cloud.google.com/sql/docs/mysql/sql-proxy), for this purpose two null resources will be created for each user added to the database, enabling this option requires the [presence of the proxy executable](https://cloud.google.com/sql/docs/mysql/sql-proxy) in the filesystem where `terraform apply` is executed.
Expand Down Expand Up @@ -33,12 +37,13 @@ CloudSQL Auth Proxy needs the CloudSQL instance to expose a public IP address in

| Name | Description | Type | Default | Required |
|------|-------------|------|---------|:--------:|
| <a name="input_cloudsql_instance_name"></a> [cloudsql\_instance\_name](#input\_cloudsql\_instance\_name) | The name of the existing Google CloudSQL Instance name. Actually only a MySQL 5.7 or 8 instance is supported. | `string` | n/a | yes |
| <a name="input_cloudsql_instance_name"></a> [cloudsql\_instance\_name](#input\_cloudsql\_instance\_name) | The name of the existing Google CloudSQL Instance name. MySQL 5.7, 8.0 and 8.4 are supported. | `string` | n/a | yes |
| <a name="input_cloudsql_privileged_user_name"></a> [cloudsql\_privileged\_user\_name](#input\_cloudsql\_privileged\_user\_name) | The name of the privileged user of the Cloud SQL instance | `string` | n/a | yes |
| <a name="input_cloudsql_privileged_user_password"></a> [cloudsql\_privileged\_user\_password](#input\_cloudsql\_privileged\_user\_password) | The password of the privileged user of the Cloud SQL instance | `string` | n/a | yes |
| <a name="input_cloudsql_proxy_host"></a> [cloudsql\_proxy\_host](#input\_cloudsql\_proxy\_host) | The host of the Cloud SQL Auth Proxy; if a value other than localhost or 127.0.0.1 (default) is entered, it is assumed that there is a CloudSQL Auth Proxy instance defined and already configured outside this module, and therefore the proxy will not be launched. | `string` | `"127.0.0.1"` | no |
| <a name="input_cloudsql_proxy_port"></a> [cloudsql\_proxy\_port](#input\_cloudsql\_proxy\_port) | Port of the Cloud SQL Auth Proxy | `string` | `"1234"` | no |
| <a name="input_database_and_user_list"></a> [database\_and\_user\_list](#input\_database\_and\_user\_list) | The list with all the databases and the relative user. Please not that you can assign only a database to a single user, the same user cannot be assigned to multiple databases. `user_host` is optional, has a default value of '%' to allow the user to connect from any host, or you can specify it for the given user for a more restrictive access. | <pre>list(object({<br/> user = string<br/> user_host = optional(string, "%")<br/> database = string<br/> }))</pre> | n/a | yes |
| <a name="input_permissions_refresh_id"></a> [permissions\_refresh\_id](#input\_permissions\_refresh\_id) | Optional identifier (use format YYYYMMDD, e.g. 20251110) used only to force Terraform to rerun the proxy/grant scripts without recreating users. Change the value whenever you need to reapply permissions. | `string` | `""` | no |
| <a name="input_project_id"></a> [project\_id](#input\_project\_id) | The ID of the project in which the resource belongs. | `string` | n/a | yes |
| <a name="input_region"></a> [region](#input\_region) | The region in which the resource belongs. | `string` | n/a | yes |
| <a name="input_terraform_start_cloud_sql_proxy"></a> [terraform\_start\_cloud\_sql\_proxy](#input\_terraform\_start\_cloud\_sql\_proxy) | If `true` terraform will automatically start the Cloud SQL Proxy instance present in the filesystem at the condition that cloudsql\_proxy\_host is set to a supported value. If `false` you have to start the Cloud SQL Proxy manually. This variable is used to prevent the creation of a Cloud SQL Proxy instance even if cloudsql\_proxy\_host has a supported value. | `bool` | `true` | no |
Expand All @@ -54,6 +59,8 @@ CloudSQL Auth Proxy needs the CloudSQL instance to expose a public IP address in
| [google_sql_database.sql_database](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/sql_database) | resource |
| [google_sql_user.sql_user](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/sql_user) | resource |
| [null_resource.execute_cloud_sql_proxy](https://registry.terraform.io/providers/hashicorp/null/latest/docs/resources/resource) | resource |
| [null_resource.force_permissions_refresh](https://registry.terraform.io/providers/hashicorp/null/latest/docs/resources/resource) | resource |
| [null_resource.grant_permissions](https://registry.terraform.io/providers/hashicorp/null/latest/docs/resources/resource) | resource |
| [null_resource.kill_cloud_sql_proxy](https://registry.terraform.io/providers/hashicorp/null/latest/docs/resources/resource) | resource |
| [random_password.sql_user_password](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/password) | resource |
| [google_sql_database_instance.cloudsql_instance](https://registry.terraform.io/providers/hashicorp/google/latest/docs/data-sources/sql_database_instance) | data source |
Expand Down
12 changes: 7 additions & 5 deletions examples/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,13 @@ resource "google_sql_user" "admin_user_mysql" {

# Add additional user and database using this this module.
module "mysql_additional_users_and_databases" {
source = "sparkfabrik/gcp-mysql-db-and-user-creation-helper/sparkfabrik"
version = "~> 0.1"
project_id = var.project_id
region = var.region
database_and_user_list = var.database_and_user_list
source = "sparkfabrik/gcp-mysql-db-and-user-creation-helper/sparkfabrik"
version = "~> 0.1"
project_id = var.project_id
region = var.region
database_and_user_list = var.database_and_user_list
# Change this value (use YYYYMMDD, e.g. 20251110) whenever you need to rerun the proxy/grant scripts without recreating users.
permissions_refresh_id = var.permissions_refresh_id
cloudsql_instance_name = google_sql_database_instance.instance.name
cloudsql_privileged_user_name = google_sql_user.admin_user_mysql.name
cloudsql_privileged_user_password = google_sql_user.admin_user_mysql.password
Expand Down
3 changes: 3 additions & 0 deletions examples/test.tfvars
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,6 @@ database_and_user_list = [
user = "user4"
}
]

# Bump this value (YYYYMMDD, e.g. 20251110) whenever you need to rerun the proxy/grant scripts without recreating users.
permissions_refresh_id = "20251110"
6 changes: 6 additions & 0 deletions examples/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,9 @@ variable "database_and_user_list" {
database = string
}))
}

variable "permissions_refresh_id" {
type = string
default = ""
description = "Change this date (YYYYMMDD, e.g. 20251110) to force rerunning the proxy/grant scripts."
}
44 changes: 41 additions & 3 deletions main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ resource "null_resource" "execute_cloud_sql_proxy" {
for_each = (((var.cloudsql_proxy_host == "localhost" || var.cloudsql_proxy_host == "127.0.0.1") && var.terraform_start_cloud_sql_proxy) ? {
for u in var.database_and_user_list : u.user => u
} : {})
lifecycle {
replace_triggered_by = [
null_resource.force_permissions_refresh.id
]
}
provisioner "local-exec" {
command = "${path.module}/scripts/execute_cloud_sql_proxy.sh"
environment = {
Expand Down Expand Up @@ -55,6 +60,31 @@ resource "google_sql_user" "sql_user" {
name = each.value.user
password = random_password.sql_user_password[each.value.user].result
host = each.value.user_host
depends_on = [
google_sql_database.sql_database
]
}

resource "null_resource" "force_permissions_refresh" {
triggers = {
refresh_id = var.permissions_refresh_id
}
}

resource "null_resource" "grant_permissions" {
for_each = { for u in var.database_and_user_list : u.user => u }

triggers = {
user = each.key
user_host = each.value.user_host
database = each.value.database
}

lifecycle {
replace_triggered_by = [
null_resource.force_permissions_refresh.id
]
}

provisioner "local-exec" {
command = "${path.module}/scripts/execute_sql.sh"
Expand All @@ -74,17 +104,24 @@ resource "google_sql_user" "sql_user" {
interpreter = [
"/bin/sh", "-c"
]
when = create
}

depends_on = [
google_sql_database.sql_database
google_sql_database.sql_database,
google_sql_user.sql_user,
null_resource.execute_cloud_sql_proxy
]
}

resource "null_resource" "kill_cloud_sql_proxy" {
for_each = (((var.cloudsql_proxy_host == "localhost" || var.cloudsql_proxy_host == "127.0.0.1") && var.terraform_start_cloud_sql_proxy) ? {
for u in var.database_and_user_list : u.user => u
} : {})
lifecycle {
replace_triggered_by = [
null_resource.force_permissions_refresh.id
]
}
provisioner "local-exec" {
command = "${path.module}/scripts/kill_cloud_sql_proxy.sh"
interpreter = [
Expand All @@ -94,6 +131,7 @@ resource "null_resource" "kill_cloud_sql_proxy" {
}
depends_on = [
google_sql_database.sql_database,
google_sql_user.sql_user
google_sql_user.sql_user,
null_resource.grant_permissions
]
}
43 changes: 31 additions & 12 deletions scripts/execute_cloud_sql_proxy.sh
Original file line number Diff line number Diff line change
@@ -1,31 +1,50 @@
#!/usr/bin/env sh

if ! [ -x "$(command -v cloud_sql_proxy)" ]; then
echo "Error: cannot find the cloud_sql_proxy executable, please install it or add to your path." >&2
set -eu

# shellcheck disable=SC3040
if (set -o pipefail 2>/dev/null); then
set -o pipefail
fi

log() {
printf '[sql-proxy] %s\n' "${1}"
}

PROXY_BIN=""
if command -v cloud_sql_proxy >/dev/null 2>&1; then
PROXY_BIN="cloud_sql_proxy"
else
log "Error: cannot find the Cloud SQL Auth Proxy executable cloud_sql_proxy. Please install it or add it to your PATH." >&2
exit 1
elif ! [ -x "$(command -v nc)" ]; then
echo "Error: Netcat is not installed." >&2
fi

if ! command -v nc >/dev/null 2>&1; then
log "Error: Netcat is not installed." >&2
exit 1
fi

SERVICE="cloud_sql_proxy"
CONNECTION_NAME="${CLOUDSDK_CORE_PROJECT}:${GCLOUD_PROJECT_REGION}:${CLOUDSQL_INSTANCE_NAME}"

if ! pgrep -x "$SERVICE" >/dev/null
then
exec cloud_sql_proxy -instances="${CLOUDSDK_CORE_PROJECT}:${GCLOUD_PROJECT_REGION}:${CLOUDSQL_INSTANCE_NAME}"="tcp:0.0.0.0:${CLOUDSQL_PROXY_PORT}" /dev/null 2>&1 &
if ! pgrep -x "$PROXY_BIN" >/dev/null; then
log "Starting Cloud SQL Auth Proxy (${PROXY_BIN}) for ${CONNECTION_NAME} on localhost:${CLOUDSQL_PROXY_PORT}."
"${PROXY_BIN}" "${CONNECTION_NAME}" --port "${CLOUDSQL_PROXY_PORT}" >/dev/null 2>&1 &
sleep 1s
else
log "Cloud SQL Auth Proxy already running; skipping start."
fi

for j in $(seq 1 10); do
READY=$(sh -c 'nc -v ${CLOUDSQL_PROXY_HOST} ${CLOUDSQL_PROXY_PORT} </dev/null; echo $?;' 2>/dev/null)
if [ "$READY" -eq 0 ]; then
echo "Connection with with CloudSQL Auth Proxy established at ${CLOUDSQL_PROXY_HOST}."
log "Connection with Cloud SQL Auth Proxy established at ${CLOUDSQL_PROXY_HOST}:${CLOUDSQL_PROXY_PORT}."
break
fi
echo "Waiting for Cloud SQL Proxy to start... $j"
log "Waiting for Cloud SQL Proxy to start (attempt ${j}/10)..."
sleep 1s
done

if [ "$READY" -eq 1 ]; then
echo "ERROR: cannot connect to the CloudSQL Auth Proxy at ${CLOUDSQL_PROXY_HOST}, please check your settings."
if [ "$READY" -ne 0 ]; then
log "ERROR: cannot connect to the Cloud SQL Auth Proxy at ${CLOUDSQL_PROXY_HOST}:${CLOUDSQL_PROXY_PORT}, please check your settings." >&2
exit 1
fi
50 changes: 40 additions & 10 deletions scripts/execute_sql.sh
Original file line number Diff line number Diff line change
@@ -1,35 +1,65 @@
#!/usr/bin/env sh

set -eu

# shellcheck disable=SC3040
if (set -o pipefail 2>/dev/null); then
set -o pipefail
fi

log() {
printf '[sql-grant] %s\n' "${1}"
}

if ! [ -x "$(command -v mysql)" ]; then
echo "Error: the mysql client is not installed or is not in your path. Please add the mysql client executable." >&2
log "Error: the mysql client is not installed or is not in your path. Please add the mysql client executable." >&2
exit 1
elif ! [ -x "$(command -v nc)" ]; then
echo "Error: Netcat is not installed." >&2
log "Error: Netcat is not installed." >&2
exit 1
fi

for j in $(seq 1 10); do
READY=$(sh -c 'nc -v ${CLOUDSQL_PROXY_HOST} ${CLOUDSQL_PROXY_PORT} </dev/null; echo $?;' 2>/dev/null)

if [ "$READY" -eq 0 ]; then
echo "Connection with with CloudSQL Auth Proxy established at ${CLOUDSQL_PROXY_HOST}."
log "Connection with CloudSQL Auth Proxy established at ${CLOUDSQL_PROXY_HOST}:${CLOUDSQL_PROXY_PORT}."
break
fi
echo "Waiting for Cloud SQL Proxy to start... $j"
log "Waiting for Cloud SQL Proxy to start (attempt ${j}/10)..."
sleep 1s
done

if [ "$READY" -eq 0 ]; then
if [ "${MYSQL_VERSION:0:9}" = "MYSQL_5_7" ]; then
mysql --host=${CLOUDSQL_PROXY_HOST} --port=${CLOUDSQL_PROXY_PORT} --user=${CLOUDSQL_PRIVILEGED_USER_NAME} --password=${CLOUDSQL_PRIVILEGED_USER_PASSWORD} --execute="REVOKE ALL PRIVILEGES, GRANT OPTION FROM '${USER}'@'${USER_HOST}'; GRANT ALL ON ${DATABASE}.* TO ${USER}@'${USER_HOST}';"
fi
USER_IDENTIFIER="'${USER}'@'${USER_HOST}'"
DATABASE_IDENTIFIER="\`${DATABASE}\`.*"

if [ "${MYSQL_VERSION:0:9}" = "MYSQL_8_0" ]; then
mysql --host=${CLOUDSQL_PROXY_HOST} --port=${CLOUDSQL_PROXY_PORT} --user=${CLOUDSQL_PRIVILEGED_USER_NAME} --password=${CLOUDSQL_PRIVILEGED_USER_PASSWORD} --execute="REVOKE cloudsqlsuperuser FROM '${USER}'@'${USER_HOST}'; GRANT ALL ON ${DATABASE}.* TO ${USER}@'${USER_HOST}';"
log "Preparing privilege statements for ${USER_IDENTIFIER} on database \`${DATABASE}\` (MySQL ${MYSQL_VERSION})."

case "${MYSQL_VERSION}" in
MYSQL_5_7*)
SQL_COMMANDS="REVOKE ALL PRIVILEGES, GRANT OPTION FROM ${USER_IDENTIFIER}; GRANT ALL PRIVILEGES ON ${DATABASE_IDENTIFIER} TO ${USER_IDENTIFIER};"
;;
MYSQL_8_0*|MYSQL_8_4*)
SQL_COMMANDS="REVOKE cloudsqlsuperuser FROM ${USER_IDENTIFIER}; SET DEFAULT ROLE NONE TO ${USER_IDENTIFIER}; GRANT ALL PRIVILEGES ON ${DATABASE_IDENTIFIER} TO ${USER_IDENTIFIER};"
;;
*)
log "ERROR: Unsupported MySQL version ${MYSQL_VERSION}." >&2
exit 1
;;
esac

printf '[sql-grant] Executing SQL statements:\n%s\n' "${SQL_COMMANDS}"

if ! MYSQL_PWD="${CLOUDSQL_PRIVILEGED_USER_PASSWORD}" mysql --host="${CLOUDSQL_PROXY_HOST}" --port="${CLOUDSQL_PROXY_PORT}" --user="${CLOUDSQL_PRIVILEGED_USER_NAME}" --execute="${SQL_COMMANDS}"; then
log "ERROR: Failed to apply privileges for ${USER_IDENTIFIER} on ${DATABASE}." >&2
exit 1
fi

log "Successfully applied privileges for ${USER_IDENTIFIER}."

exit 0
else
echo "ERROR: cannot connect to the CloudSQL Auth Proxy at ${CLOUDSQL_PROXY_HOST}, please check your settings."
log "ERROR: cannot connect to the CloudSQL Auth Proxy at ${CLOUDSQL_PROXY_HOST}:${CLOUDSQL_PROXY_PORT}, please check your settings." >&2
exit 1
fi
25 changes: 18 additions & 7 deletions scripts/kill_cloud_sql_proxy.sh
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
#!/usr/bin/env sh

SERVICE="cloud_sql_proxy"
set -eu

if pgrep -x "$SERVICE" >/dev/null; then
# It's better to take some time and to wait for the other tasks to finish
# before killing the proxy; do not entering a sleep time, can lead to a
# race condition error when simultaneously creating and destroying resources.
# shellcheck disable=SC3040
if (set -o pipefail 2>/dev/null); then
set -o pipefail
fi

log() {
printf '[sql-proxy] %s\n' "${1}"
}

PROXY_BIN="cloud_sql_proxy"
if pgrep -x "$PROXY_BIN" >/dev/null; then
log "Detected running ${PROXY_BIN}; waiting 5 seconds before shutdown to avoid race conditions."
sleep 5s
PID_CLOUD_SQL_PROXY=$(pgrep -x ${SERVICE})
kill "$PID_CLOUD_SQL_PROXY" || true
# Obtain the PID of the running Cloud SQL Auth Proxy and terminate gently.
PID="$(pgrep -x "$PROXY_BIN")"
log "Stopping ${PROXY_BIN} (PID(s): ${PID})."

kill "${PID}" || true
fi
13 changes: 12 additions & 1 deletion variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ variable "region" {

variable "cloudsql_instance_name" {
type = string
description = "The name of the existing Google CloudSQL Instance name. Actually only a MySQL 5.7 or 8 instance is supported."
description = "The name of the existing Google CloudSQL Instance name. MySQL 5.7, 8.0 and 8.4 are supported."
}

variable "terraform_start_cloud_sql_proxy" {
Expand Down Expand Up @@ -50,3 +50,14 @@ variable "database_and_user_list" {
}))
description = "The list with all the databases and the relative user. Please not that you can assign only a database to a single user, the same user cannot be assigned to multiple databases. `user_host` is optional, has a default value of '%' to allow the user to connect from any host, or you can specify it for the given user for a more restrictive access."
}

variable "permissions_refresh_id" {
type = string
default = ""
description = "Optional identifier (use format YYYYMMDD, e.g. 20251110) used only to force Terraform to rerun the proxy/grant scripts without recreating users. Change the value whenever you need to reapply permissions."

validation {
condition = var.permissions_refresh_id == "" || can(regex("^\\d{8}$", var.permissions_refresh_id))
error_message = "Set permissions_refresh_id to an 8-digit date in the form YYYYMMDD (e.g. 20251110) or leave it empty."
}
}
1 change: 0 additions & 1 deletion versions.tf
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,3 @@ terraform {
}
}
}