From bb855f267dda81dda690585675ff28031d7e15d5 Mon Sep 17 00:00:00 2001 From: Alexey Basinov Date: Wed, 18 Jun 2025 08:38:03 -0700 Subject: [PATCH 01/12] Add support for multi-node Data Guard deployments --- terraform/main.tf | 193 ++++++++++++++++++++--------- terraform/scripts/setup.sh.tpl | 95 ++++++++------ terraform/terraform.tfvars.example | 18 ++- terraform/variables.tf | 86 +++++++++---- terraform/versions.tf | 4 + 5 files changed, 270 insertions(+), 126 deletions(-) diff --git a/terraform/main.tf b/terraform/main.tf index 77a35e15a..8351b35e6 100644 --- a/terraform/main.tf +++ b/terraform/main.tf @@ -79,70 +79,161 @@ locals { additional_disks = concat(local.fs_disks, local.asm_disks) project_id = var.project_id + + is_multi_instance = ( + var.zone1 != "" && var.zone2 != "" + ) + + instances = local.is_multi_instance ? { + node1 = { + name = "${var.instance_name}-1" + zone = var.zone1 + region = var.region1 + network = null + subnetwork = "projects/${var.project_id}/regions/${var.region1}/subnetworks/${var.subnetwork1}" + } + node2 = { + name = "${var.instance_name}-2" + zone = var.zone2 + region = var.region2 + network = null + subnetwork = "projects/${var.project_id}/regions/${var.region2}/subnetworks/${var.subnetwork2}" + } + } : { + default = { + name = var.instance_name + zone = var.zone + region = var.region + + # Include exactly one of these - never both + network = var.network + subnetwork = var.network == "" && var.subnetwork != "" ? "projects/${var.project_id}/regions/${var.region}/subnetworks/${var.subnetwork}": null + } + } } -module "instance_template" { - source = "terraform-google-modules/vm/google//modules/instance_template" - version = "~> 13.0" - - name_prefix = format("%s-template", var.instance_name) - region = var.region - project_id = local.project_id - network = coalesce(var.network, var.subnetwork) - subnetwork = coalesce(var.subnetwork, var.network) - subnetwork_project = local.project_id - service_account = { - email = var.vm_service_account - scopes = ["https://www.googleapis.com/auth/cloud-platform"] + +data "google_compute_image" "os_image" { + family = var.source_image_family + project = var.source_image_project +} + +resource "time_static" "template_suffix" {} + +locals { + template_suffix = formatdate("YYYYMMDDhhmmss", time_static.template_suffix.rfc3339) +} + +resource "google_compute_instance_template" "default" { + name = "${var.instance_name}-${local.template_suffix}" + project = var.project_id + machine_type = var.machine_type + + network_interface { + # gets overridden during instance creation + network = "default" + } + disk { + boot = true + auto_delete = true + source_image = data.google_compute_image.os_image.self_link + disk_type = var.boot_disk_type + disk_size_gb = var.boot_disk_size_gb } - machine_type = var.machine_type - source_image_family = var.source_image_family - source_image_project = var.source_image_project - disk_size_gb = var.boot_disk_size_gb - disk_type = var.boot_disk_type - auto_delete = true + dynamic "disk" { + for_each = local.additional_disks + content { + auto_delete = lookup(disk.value, "auto_delete", null) + boot = lookup(disk.value, "boot", null) + device_name = lookup(disk.value, "device_name", null) + disk_size_gb = lookup(disk.value, "disk_size_gb", null) + disk_type = lookup(disk.value, "disk_type", null) + labels = lookup(disk.value, "disk_labels", null) + } + } + service_account { + email = var.vm_service_account + scopes = ["cloud-platform"] + } metadata = { metadata_startup_script = var.metadata_startup_script enable-oslogin = "TRUE" } - additional_disks = local.additional_disks - tags = var.network_tags } -module "compute_instance" { - source = "terraform-google-modules/vm/google//modules/compute_instance" - version = "~> 13.0" - - region = var.region - zone = var.zone - network = coalesce(var.network, var.subnetwork) - subnetwork = coalesce(var.subnetwork, var.network) - subnetwork_project = local.project_id - hostname = var.instance_name - instance_template = module.instance_template.self_link - deletion_protection = false - - access_config = var.assign_public_ip ? [{ - nat_ip = null - network_tier = "PREMIUM" - }] : [] +resource "google_compute_instance_from_template" "database_vm" { + for_each = local.instances + + name = each.value.name + zone = each.value.zone + project = var.project_id + source_instance_template = google_compute_instance_template.default.self_link + + # Only one of network or subnetwork is expected to be set per instance, not both. + # Otherwise, Terraform will return an error: + # Exactly one of 'network' or 'subnetwork' must be provided. + dynamic "network_interface" { + for_each = (each.value.network != "" || each.value.subnetwork != "") ? [1] : [] + content { + network = each.value.network + subnetwork = each.value.subnetwork + + dynamic "access_config" { + for_each = var.assign_public_ip ? [1] : [] + content {} + } + } + } + } resource "random_id" "suffix" { byte_length = 4 } +locals { + oracle_nodes = [ + for vm in google_compute_instance_from_template.database_vm : { + name = vm.name + zone = vm.zone + ip = vm.network_interface[0].network_ip + } + ] +} + +locals { + common_flags = join(" ", compact([ + length(local.asm_disk_config) > 0 ? "--ora-asm-disks-json '${jsonencode(local.asm_disk_config)}'" : "", + length(local.data_mounts_config) > 0 ? "--ora-data-mounts-json '${jsonencode(local.data_mounts_config)}'" : "", + "--swap-blk-device /dev/disk/by-id/google-swap", + var.ora_swlib_bucket != "" ? "--ora-swlib-bucket ${var.ora_swlib_bucket}" : "", + var.ora_version != "" ? "--ora-version ${var.ora_version}" : "", + var.ora_backup_dest != "" ? "--backup-dest ${var.ora_backup_dest}" : "", + var.ora_db_name != "" ? "--ora-db-name ${var.ora_db_name}" : "", + var.ora_db_container != "" ? "--ora-db-container ${var.ora_db_container}" : "", + var.ntp_pref != "" ? "--ntp-pref ${var.ntp_pref}" : "", + var.ora_release != "" ? "--ora-release ${var.ora_release}" : "", + var.ora_edition != "" ? "--ora-edition ${var.ora_edition}" : "", + var.ora_listener_port != "" ? "--ora-listener-port ${var.ora_listener_port}" : "", + var.ora_redo_log_size != "" ? "--ora-redo-log-size ${var.ora_redo_log_size}" : "", + var.db_password_secret != "" ? "--db-password-secret ${var.db_password_secret}" : "", + var.oracle_metrics_secret != "" ? "--oracle-metrics-secret ${var.oracle_metrics_secret}" : "", + var.install_workload_agent ? "--install-workload-agent" : "", + var.skip_database_config ? "--skip-database-config" : "" + ])) +} + resource "google_compute_instance" "control_node" { project = var.project_id name = "${var.control_node_name_prefix}-${random_id.suffix.hex}" machine_type = var.control_node_machine_type zone = var.zone - + scheduling { max_run_duration { seconds = 604800 @@ -174,33 +265,15 @@ resource "google_compute_instance" "control_node" { metadata_startup_script = templatefile("${path.module}/scripts/setup.sh.tpl", { gcs_source = var.gcs_source - instance_name = module.compute_instance.instances_details[0].name - instance_zone = module.compute_instance.instances_details[0].zone - ip_addr = module.compute_instance.instances_details[0].network_interface[0].network_ip - asm_disk_config = jsonencode(local.asm_disk_config) - data_mounts_config = jsonencode(local.data_mounts_config) - swap_blk_device = "/dev/disk/by-id/google-swap" - ora_swlib_bucket = var.ora_swlib_bucket - ora_version = var.ora_version - ora_backup_dest = var.ora_backup_dest - ora_db_name = var.ora_db_name - ora_db_container = var.ora_db_container - ntp_pref = var.ntp_pref - ora_release = var.ora_release - ora_edition = var.ora_edition - ora_listener_port = var.ora_listener_port - ora_redo_log_size = var.ora_redo_log_size - db_password_secret = var.db_password_secret - install_workload_agent = var.install_workload_agent - oracle_metrics_secret = var.oracle_metrics_secret - skip_database_config = var.skip_database_config + oracle_nodes_json = jsonencode(local.oracle_nodes) + common_flags = local.common_flags }) metadata = { enable-oslogin = "TRUE" } - depends_on = [module.compute_instance] + depends_on = [google_compute_instance_from_template.database_vm] } output "control_node_log_url" { diff --git a/terraform/scripts/setup.sh.tpl b/terraform/scripts/setup.sh.tpl index 7327b3a26..e0b063b03 100644 --- a/terraform/scripts/setup.sh.tpl +++ b/terraform/scripts/setup.sh.tpl @@ -26,20 +26,6 @@ DEST_DIR="/oracle-toolkit" apt-get update apt-get install -y ansible python3-jmespath unzip -echo "Triggering SSH key creation via OS Login by running a one-time gcloud compute ssh command." -echo "This ensures that a persistent SSH key pair is created and associated with your Google Account." -echo "The private auto-generated ssh key (~/.ssh/google_compute_engine) will be used by Ansible to connect to the VM and run playbooks remotely." -echo "Command:" -echo "gcloud compute ssh '${instance_name}' --zone='${instance_zone}' --internal-ip --quiet --command whoami" - -timeout 2m bash -c 'until gcloud compute ssh "${instance_name}" --zone="${instance_zone}" --internal-ip --quiet --command whoami; do - echo "Waiting for SSH to become available on '${instance_name}'..." - sleep 5 -done' || { - echo "ERROR: Timed out waiting for SSH" - exit 1 -} - control_node_sa="$(curl -s http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/email -H 'Metadata-Flavor: Google')" echo "Downloading '${gcs_source}' to /tmp" if ! gsutil cp "${gcs_source}" /tmp/; then @@ -51,32 +37,63 @@ mkdir -p "$DEST_DIR" echo "Extracting files from '$zip_file' to '$DEST_DIR'" unzip -o "/tmp/$zip_file" -d "$DEST_DIR" -ssh_user="$(gcloud compute os-login describe-profile --format='value(posixAccounts[0].username)')" -if [[ -z "$ssh_user" ]]; then - echo "ERROR: Failed to extract the POSIX username. This may be due to OS Login not being enabled or missing IAM permissions." - exit 1 +num_nodes="$(echo '${oracle_nodes_json}' | jq "length")" +echo "num_nodes=$num_nodes" + +if [[ "$num_nodes" > 1 ]]; then + # extract the IP of the VM whose name ends with "-1" + primary_ip="$(echo '${oracle_nodes_json}' | jq -r '.[] | select(.name | endswith("-1")) | .ip')" + if [[ -z "$primary_ip" ]]; then + echo "ERROR: Could not find a primary node ending with '-1'." + exit 1 + fi + echo "PRIMARY_IP: $primary_ip" fi cd "$DEST_DIR" -bash install-oracle.sh \ ---instance-ssh-user "$ssh_user" \ ---instance-ssh-key /root/.ssh/google_compute_engine \ -%{ if ip_addr != "" }--instance-ip-addr "${ip_addr}" %{ endif } \ -%{ if asm_disk_config != "" }--ora-asm-disks-json '${asm_disk_config}' %{ endif } \ -%{ if data_mounts_config != "" }--ora-data-mounts-json '${data_mounts_config}' %{ endif } \ -%{ if swap_blk_device != "" }--swap-blk-device "${swap_blk_device}" %{ endif } \ -%{ if ora_swlib_bucket != "" }--ora-swlib-bucket "${ora_swlib_bucket}" %{ endif } \ -%{ if ora_version != "" }--ora-version "${ora_version}" %{ endif } \ -%{ if ora_backup_dest != "" }--backup-dest "${ora_backup_dest}" %{ endif } \ -%{ if ora_db_name != "" }--ora-db-name "${ora_db_name}" %{ endif } \ -%{ if ora_db_container != "" }--ora-db-container "${ora_db_container}" %{ endif } \ -%{ if ntp_pref != "" }--ntp-pref "${ntp_pref}" %{ endif } \ -%{ if ora_release != "" }--ora-release "${ora_release}" %{ endif } \ -%{ if ora_edition != "" }--ora-edition "${ora_edition}" %{ endif } \ -%{ if ora_listener_port != "" }--ora-listener-port "${ora_listener_port}" %{ endif } \ -%{ if ora_redo_log_size != "" }--ora-redo-log-size "${ora_redo_log_size}" %{ endif } \ -%{ if skip_database_config }--skip-database-config %{ endif } \ -%{ if install_workload_agent }--install-workload-agent %{ endif } \ -%{ if oracle_metrics_secret != "" }--oracle-metrics-secret "${oracle_metrics_secret}" %{ endif } \ -%{ if db_password_secret != "" }--db-password-secret "${db_password_secret}" %{ endif } +for node in $(echo '${oracle_nodes_json}' | jq -c '.[]'); do + node_name="$(echo "$node" | jq -r '.name')" + node_ip="$(echo "$node" | jq -r '.ip')" + node_zone="$(echo "$node" | jq -r '.zone')" + + echo "Triggering SSH key creation via OS Login by running a one-time gcloud compute ssh command." + echo "This ensures that a persistent SSH key pair is created and associated with your Google Account." + echo "The private auto-generated ssh key (~/.ssh/google_compute_engine) will be used by Ansible to connect to the VM and run playbooks remotely." + echo "Command:" + echo "gcloud compute ssh '$node_name' --zone='$node_zone' --internal-ip --quiet --command whoami" + + timeout 2m bash -c "until gcloud compute ssh \"$node_name\" --zone=\"$node_zone\" --internal-ip --quiet --command whoami; do + echo \"Waiting for SSH to become available on '$node_name'...\" + sleep 5 + done" || { + echo "ERROR: Timed out waiting for SSH" + exit 1 + } + + ssh_user="$(gcloud compute os-login describe-profile --format='value(posixAccounts[0].username)')" + if [[ -z "$ssh_user" ]]; then + echo "ERROR: Failed to extract the POSIX username. This may be due to OS Login not being enabled or missing IAM permissions." + exit 1 + fi + + if [[ "$num_nodes" -eq 1 || "$node_name" == *"-1" ]]; then + echo "Configuring PRIMARY or SINGLE instance: $node_name, IP: $node_ip, Zone: $node_zone" + bash install-oracle.sh \ + --cluster-type NONE \ + --instance-ip-addr "$node_ip" \ + --instance-ssh-user "$ssh_user" \ + --instance-ssh-key /root/.ssh/google_compute_engine \ + ${common_flags} + elif [[ "$node_name" == *"-2" ]]; then + echo "Configuring STANDBY node: $node_name, IP: $node_ip, Zone: $node_zone" + bash install-oracle.sh \ + --cluster-type DG \ + --primary-ip-addr "$primary_ip" \ + --instance-ip-addr "$node_ip" \ + --instance-ssh-user "$ssh_user" \ + --instance-ssh-key /root/.ssh/google_compute_engine \ + ${common_flags} + fi +done + diff --git a/terraform/terraform.tfvars.example b/terraform/terraform.tfvars.example index f1302eeb8..374e33b8d 100644 --- a/terraform/terraform.tfvars.example +++ b/terraform/terraform.tfvars.example @@ -16,13 +16,25 @@ # General settings gcs_source = "gs://your-gcs-bucket/oracle-toolkit.zip" ora_swlib_bucket = "gs://your-oracle-software-bucket" +project_id = "your-project-id" +vm_service_account = "your-vm-service-account@your-project-id.iam.gserviceaccount.com" +control_node_service_account = "your-control-node-service-account@your-project-id.iam.gserviceaccount.com" + +# Either the single-instance or the multi-instance input variables must be specified. + +# Single-instance deployment region = "us-central1" zone = "us-central1-b" -project_id = "your-project-id" network = "default" subnetwork = "default" -vm_service_account = "your-vm-service-account@your-project-id.iam.gserviceaccount.com" -control_node_service_account = "your-control-node-service-account@your-project-id.iam.gserviceaccount.com" + +# Multi-instance Data Guard deployment +region1 = "us-central1" +region2 = "us-central1" +subnetwork1 = "default" +subnetwork2 = "default" +zone1 = "us-central1-b" +zone2 = "us-central1-c" # Instance settings instance_name = "orcl" diff --git a/terraform/variables.tf b/terraform/variables.tf index 78d7d1716..239060698 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -200,12 +200,6 @@ variable "project_id" { type = string } -variable "region" { - description = "The GCP region where the instance and related resources will be deployed (e.g., us-central1)." - type = string - default = "us-central1" -} - variable "vm_service_account" { description = "The service account used for managing compute instance permissions." type = string @@ -238,24 +232,6 @@ variable "source_image_project" { default = "oracle-linux-cloud" } -variable "network" { - description = "The name of the GCP network to which the instance will be attached." - type = string - default = "default" -} - -variable "subnetwork" { - description = "The name of the GCP subnetwork to which the instance will be attached; customize if using custom subnet creation mode." - type = string - default = "" -} - -variable "zone" { - description = "The specific availability zone within the selected GCP region (e.g., us-central1-b)." - type = string - default = "us-central1-b" -} - variable "assign_public_ip" { description = "Whether to assign a public IP address to the control node VM. Set to false if the environment already has internet access via a Cloud NAT." type = bool @@ -304,3 +280,65 @@ variable "skip_database_config" { type = bool default = false } + +# Single-instance input variables +variable "region" { + description = "The GCP region where the instance and related resources will be deployed (e.g., us-central1)." + type = string + default = "us-central1" +} + +variable "zone" { + description = "The specific availability zone within the selected GCP region (e.g., us-central1-b)." + type = string + default = "us-central1-b" +} + +variable "network" { + description = "The name of the GCP network to which the instance will be attached." + type = string + default = "default" +} + +variable "subnetwork" { + description = "The name of the GCP subnetwork to which the instance will be attached; customize if using custom subnet creation mode." + type = string + default = "" +} + +# Multi-instance input variables +variable "region1" { + description = "The region of the first VM in multi-instance setup. Used to build the subnetwork self-link." + type = string + default = "" +} + +variable "region2" { + description = "The region of the second VM in multi-instance setup. Used to build the subnetwork self-link." + type = string + default = "" +} + +variable "zone1" { + description = "The GCP zone to deploy the first VM in multi-instance setup. Must be within region1." + type = string + default = "" +} + +variable "zone2" { + description = "The GCP zone to deploy the second VM in multi-instance setup. Must be within region2." + type = string + default = "" +} + +variable "subnetwork1" { + description = "The name of the subnetwork to use for the first VM in multi-instance setup. Must exist in region1." + type = string + default = "" +} + +variable "subnetwork2" { + description = "The name of the subnetwork to use for the second VM in multi-instance setup. Must exist in region2." + type = string + default = "" +} diff --git a/terraform/versions.tf b/terraform/versions.tf index 76c040ab0..c5a604987 100644 --- a/terraform/versions.tf +++ b/terraform/versions.tf @@ -23,5 +23,9 @@ terraform { source = "hashicorp/random" version = "~> 3.7.2" } + time = { + source = "hashicorp/time" + version = "~> 0.9" + } } } From 6d8b11a03fac7fd07803bf9a9d2713caaa18bb3c Mon Sep 17 00:00:00 2001 From: Alexey Basinov Date: Mon, 14 Jul 2025 17:44:17 -0700 Subject: [PATCH 02/12] Use subnetwork1 and zone1 for single-instance and primary node in multi-instance Data Guard deployments --- terraform/main.tf | 45 +++++++++--------------- terraform/terraform.tfvars.example | 18 +++------- terraform/variables.tf | 56 +++++++----------------------- 3 files changed, 34 insertions(+), 85 deletions(-) diff --git a/terraform/main.tf b/terraform/main.tf index 8351b35e6..241432d22 100644 --- a/terraform/main.tf +++ b/terraform/main.tf @@ -81,33 +81,25 @@ locals { project_id = var.project_id is_multi_instance = ( - var.zone1 != "" && var.zone2 != "" + var.zone1 != "" && var.zone2 != "" && var.subnetwork1 != "" && var.subnetwork2 != "" ) instances = local.is_multi_instance ? { node1 = { name = "${var.instance_name}-1" zone = var.zone1 - region = var.region1 - network = null - subnetwork = "projects/${var.project_id}/regions/${var.region1}/subnetworks/${var.subnetwork1}" + subnetwork = var.subnetwork1 } node2 = { name = "${var.instance_name}-2" zone = var.zone2 - region = var.region2 - network = null - subnetwork = "projects/${var.project_id}/regions/${var.region2}/subnetworks/${var.subnetwork2}" + subnetwork = var.subnetwork2 } } : { default = { name = var.instance_name - zone = var.zone - region = var.region - - # Include exactly one of these - never both - network = var.network - subnetwork = var.network == "" && var.subnetwork != "" ? "projects/${var.project_id}/regions/${var.region}/subnetworks/${var.subnetwork}": null + zone = var.zone1 + subnetwork = var.subnetwork1 } } } @@ -174,19 +166,12 @@ resource "google_compute_instance_from_template" "database_vm" { project = var.project_id source_instance_template = google_compute_instance_template.default.self_link - # Only one of network or subnetwork is expected to be set per instance, not both. - # Otherwise, Terraform will return an error: - # Exactly one of 'network' or 'subnetwork' must be provided. - dynamic "network_interface" { - for_each = (each.value.network != "" || each.value.subnetwork != "") ? [1] : [] - content { - network = each.value.network - subnetwork = each.value.subnetwork + network_interface { + subnetwork = each.value.subnetwork - dynamic "access_config" { - for_each = var.assign_public_ip ? [1] : [] - content {} - } + dynamic "access_config" { + for_each = var.assign_public_ip ? [1] : [] + content {} } } @@ -224,7 +209,9 @@ locals { var.db_password_secret != "" ? "--db-password-secret ${var.db_password_secret}" : "", var.oracle_metrics_secret != "" ? "--oracle-metrics-secret ${var.oracle_metrics_secret}" : "", var.install_workload_agent ? "--install-workload-agent" : "", - var.skip_database_config ? "--skip-database-config" : "" + var.skip_database_config ? "--skip-database-config" : "", + var.ora_pga_target_mb != "" ? "--ora-pga-target-mb ${var.ora_pga_target_mb}" : "", + var.ora_sga_target_mb != "" ? "--ora-sga-target-mb ${var.ora_pga_target_mb}": "" ])) } @@ -232,7 +219,7 @@ resource "google_compute_instance" "control_node" { project = var.project_id name = "${var.control_node_name_prefix}-${random_id.suffix.hex}" machine_type = var.control_node_machine_type - zone = var.zone + zone = var.zone1 scheduling { max_run_duration { @@ -248,8 +235,7 @@ resource "google_compute_instance" "control_node" { } network_interface { - network = coalesce(var.network, var.subnetwork) - subnetwork = coalesce(var.subnetwork, var.network) + subnetwork = var.subnetwork1 subnetwork_project = local.project_id dynamic "access_config" { @@ -267,6 +253,7 @@ resource "google_compute_instance" "control_node" { gcs_source = var.gcs_source oracle_nodes_json = jsonencode(local.oracle_nodes) common_flags = local.common_flags + deployment_name = var.deployment_name }) metadata = { diff --git a/terraform/terraform.tfvars.example b/terraform/terraform.tfvars.example index 374e33b8d..809d2684d 100644 --- a/terraform/terraform.tfvars.example +++ b/terraform/terraform.tfvars.example @@ -20,21 +20,13 @@ project_id = "your-project-id" vm_service_account = "your-vm-service-account@your-project-id.iam.gserviceaccount.com" control_node_service_account = "your-control-node-service-account@your-project-id.iam.gserviceaccount.com" -# Either the single-instance or the multi-instance input variables must be specified. - -# Single-instance deployment -region = "us-central1" -zone = "us-central1-b" -network = "default" -subnetwork = "default" - -# Multi-instance Data Guard deployment -region1 = "us-central1" -region2 = "us-central1" -subnetwork1 = "default" -subnetwork2 = "default" +# Primary node for the Single-instance deployment zone1 = "us-central1-b" +subnetwork1 = "default" + +# Standby node for the Multi-instance Data Guard deployment zone2 = "us-central1-c" +subnetwork2 = "default" # Instance settings instance_name = "orcl" diff --git a/terraform/variables.tf b/terraform/variables.tf index f6e545586..8dba67288 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -281,65 +281,35 @@ variable "skip_database_config" { default = false } -# Single-instance input variables -variable "region" { - description = "The GCP region where the instance and related resources will be deployed (e.g., us-central1)." - type = string - default = "us-central1" -} - -variable "zone" { - description = "The specific availability zone within the selected GCP region (e.g., us-central1-b)." - type = string - default = "us-central1-b" -} - -variable "network" { - description = "The name of the GCP network to which the instance will be attached." - type = string - default = "default" -} - -variable "subnetwork" { - description = "The name of the GCP subnetwork to which the instance will be attached; customize if using custom subnet creation mode." - type = string - default = "" -} - -# Multi-instance input variables -variable "region1" { - description = "The region of the first VM in multi-instance setup. Used to build the subnetwork self-link." - type = string - default = "" -} - -variable "region2" { - description = "The region of the second VM in multi-instance setup. Used to build the subnetwork self-link." - type = string - default = "" -} - variable "zone1" { - description = "The GCP zone to deploy the first VM in multi-instance setup. Must be within region1." + description = "The GCP zone for deploying the instance in single-instance deployments, or for the primary node in multi-instance Data Guard deployments." type = string - default = "" + default = "us-central1-b" } variable "zone2" { - description = "The GCP zone to deploy the second VM in multi-instance setup. Must be within region2." + description = "The GCP zone for deploying the secondary node in a multi-instance Data Guard deployment." type = string default = "" } variable "subnetwork1" { - description = "The name of the subnetwork to use for the first VM in multi-instance setup. Must exist in region1." + description = "The Resource URI of the GCP subnetwork to attach the instance to. Used for single-instance deployments and for the primary node in multi-instance Data Guard deployments." type = string + validation { + condition = can(regex("^projects/.+/regions/.+/subnetworks/.+$", var.subnetwork1)) + error_message = "Must be in the format: 'projects//regions//subnetworks/'." + } default = "" } variable "subnetwork2" { - description = "The name of the subnetwork to use for the second VM in multi-instance setup. Must exist in region2." + description = "The Resource URI of the GCP subnetwork to attach the secondary node to in a multi-instance Data Guard deployment." type = string + validation { + condition = var.subnetwork2 == "" || can(regex("^projects/.+/regions/.+/subnetworks/.+$", var.subnetwork2)) + error_message = "Must be in the format: 'projects//regions//subnetworks/'." + } default = "" } From a3fbd76a53b3edc40664689c05712bcf21052881 Mon Sep 17 00:00:00 2001 From: Alexey Basinov Date: Mon, 14 Jul 2025 18:03:08 -0700 Subject: [PATCH 03/12] update subnetwork1 in terraform.tfvars.example --- terraform/terraform.tfvars.example | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/terraform/terraform.tfvars.example b/terraform/terraform.tfvars.example index 809d2684d..784255834 100644 --- a/terraform/terraform.tfvars.example +++ b/terraform/terraform.tfvars.example @@ -22,11 +22,11 @@ control_node_service_account = "your-control-node-service-account@your-project-i # Primary node for the Single-instance deployment zone1 = "us-central1-b" -subnetwork1 = "default" +subnetwork1 = "projects/your-project/regions/us-central1/subnetworks/default" # Standby node for the Multi-instance Data Guard deployment zone2 = "us-central1-c" -subnetwork2 = "default" +subnetwork2 = "projects/your-project/regions/us-central1/subnetworks/default" # Instance settings instance_name = "orcl" From 37981fe87cc523321dc0ab157b20623cdfd793ab Mon Sep 17 00:00:00 2001 From: Alexey Basinov Date: Mon, 14 Jul 2025 18:05:21 -0700 Subject: [PATCH 04/12] update validation block for subnetwork1 --- terraform/variables.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/terraform/variables.tf b/terraform/variables.tf index 8dba67288..d6f174baa 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -297,7 +297,7 @@ variable "subnetwork1" { description = "The Resource URI of the GCP subnetwork to attach the instance to. Used for single-instance deployments and for the primary node in multi-instance Data Guard deployments." type = string validation { - condition = can(regex("^projects/.+/regions/.+/subnetworks/.+$", var.subnetwork1)) + condition = var.subnetwork1 == "" || can(regex("^projects/.+/regions/.+/subnetworks/.+$", var.subnetwork1)) error_message = "Must be in the format: 'projects//regions//subnetworks/'." } default = "" From b060ec6490f718d9a99275083d2a62696802941e Mon Sep 17 00:00:00 2001 From: Alexey Basinov Date: Tue, 15 Jul 2025 10:42:44 -0700 Subject: [PATCH 05/12] create wlmagent user only on the primary node --- roles/workload-agent/tasks/metric_collection.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/roles/workload-agent/tasks/metric_collection.yml b/roles/workload-agent/tasks/metric_collection.yml index 12fa28b40..5f46ad038 100644 --- a/roles/workload-agent/tasks/metric_collection.yml +++ b/roles/workload-agent/tasks/metric_collection.yml @@ -14,6 +14,7 @@ --- - name: Fetch workload agent user password from Secret Manager + delegate_to: primary1 command: gcloud --quiet secrets versions access {{ oracle_metrics_secret }} register: result changed_when: false @@ -24,12 +25,14 @@ tags: workload-agent - name: Validate password format + delegate_to: primary1 fail: msg: "Invalid password format. It must match this pattern: {{ password_pattern }}" when: not (result.stdout is match(password_pattern)) tags: workload-agent - name: Create Oracle user for Google Cloud Agent for Compute Workloads + delegate_to: primary1 become: true become_user: "{{ oracle_user }}" shell: | From 45ffaed740fd64d918a4dea0738414d0ca3c1061 Mon Sep 17 00:00:00 2001 From: Alexey Basinov Date: Tue, 15 Jul 2025 14:17:45 -0700 Subject: [PATCH 06/12] skip creating wlmagent db user on the standby node --- ...metric_collection.yml => create_db_user.yml} | 0 roles/workload-agent/tasks/main.yml | 17 +++++++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) rename roles/workload-agent/tasks/{metric_collection.yml => create_db_user.yml} (100%) diff --git a/roles/workload-agent/tasks/metric_collection.yml b/roles/workload-agent/tasks/create_db_user.yml similarity index 100% rename from roles/workload-agent/tasks/metric_collection.yml rename to roles/workload-agent/tasks/create_db_user.yml diff --git a/roles/workload-agent/tasks/main.yml b/roles/workload-agent/tasks/main.yml index 21054c829..d1fb0f4b1 100644 --- a/roles/workload-agent/tasks/main.yml +++ b/roles/workload-agent/tasks/main.yml @@ -18,9 +18,22 @@ file: install.yml tags: workload-agent -- name: Configure Google Cloud Agent for Compute Workloads to collect Oracle metrics and send them to Google Cloud Monitoring +- name: Create a database user for the Google Cloud Agent for Compute Workloads to collect Oracle metrics include_tasks: - file: metric_collection.yml + file: create_db_user.yml when: - oracle_metrics_secret | length > 0 + - "'primary1' in group_names" + tags: workload-agent + +- name: Copy workload-agent's configuration file to the database VM + template: + src: "configuration.json.j2" + dest: "/etc/google-cloud-workload-agent/configuration.json" + owner: root + group: root + mode: u=rw,go=r + when: + - oracle_metrics_secret | length > 0 + notify: Restart workload-agent tags: workload-agent From 4012f9b04c7298fa1d7b5c40b9071723a7808762 Mon Sep 17 00:00:00 2001 From: Alexey Basinov Date: Tue, 15 Jul 2025 14:25:34 -0700 Subject: [PATCH 07/12] set var.data_guard_protection_mode in main.tf --- terraform/main.tf | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/terraform/main.tf b/terraform/main.tf index 27acf71bb..b0fb1f8a1 100644 --- a/terraform/main.tf +++ b/terraform/main.tf @@ -211,7 +211,8 @@ locals { var.install_workload_agent ? "--install-workload-agent" : "", var.skip_database_config ? "--skip-database-config" : "", var.ora_pga_target_mb != "" ? "--ora-pga-target-mb ${var.ora_pga_target_mb}" : "", - var.ora_sga_target_mb != "" ? "--ora-sga-target-mb ${var.ora_pga_target_mb}": "" + var.ora_sga_target_mb != "" ? "--ora-sga-target-mb ${var.ora_pga_target_mb}": "", + var.data_guard_protection_mode != "" ? "--data-guard-protection-mode ${var.data_guard_protection_mode}": "" ])) } From 5b2b7b9925bf75b43f493adc9c9d8eda009e39e4 Mon Sep 17 00:00:00 2001 From: Alexey Basinov Date: Tue, 15 Jul 2025 14:29:42 -0700 Subject: [PATCH 08/12] remove delegate_to from create_db_user.yml --- roles/workload-agent/tasks/create_db_user.yml | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/roles/workload-agent/tasks/create_db_user.yml b/roles/workload-agent/tasks/create_db_user.yml index 5f46ad038..ea3f95e4f 100644 --- a/roles/workload-agent/tasks/create_db_user.yml +++ b/roles/workload-agent/tasks/create_db_user.yml @@ -14,7 +14,6 @@ --- - name: Fetch workload agent user password from Secret Manager - delegate_to: primary1 command: gcloud --quiet secrets versions access {{ oracle_metrics_secret }} register: result changed_when: false @@ -25,14 +24,12 @@ tags: workload-agent - name: Validate password format - delegate_to: primary1 fail: msg: "Invalid password format. It must match this pattern: {{ password_pattern }}" when: not (result.stdout is match(password_pattern)) tags: workload-agent - name: Create Oracle user for Google Cloud Agent for Compute Workloads - delegate_to: primary1 become: true become_user: "{{ oracle_user }}" shell: | @@ -49,13 +46,3 @@ PATH: "{{ oracle_home }}/bin:/usr/local/bin:/bin:/usr/bin:/usr/local/sbin:/usr/sbin" no_log: true tags: workload-agent - -- name: Copy workload-agent's configuration file to the database VM - template: - src: "configuration.json.j2" - dest: "/etc/google-cloud-workload-agent/configuration.json" - owner: root - group: root - mode: u=rw,go=r - notify: Restart workload-agent - tags: workload-agent From 754a123af1a48aca2f9fe94ba516922246d3e02c Mon Sep 17 00:00:00 2001 From: Alexey Basinov Date: Wed, 16 Jul 2025 12:55:47 -0700 Subject: [PATCH 09/12] refactor terraform's main.tf and startup.sh --- roles/workload-agent/tasks/main.yml | 5 +- terraform/main.tf | 48 ++++++------- terraform/scripts/setup.sh.tpl | 100 +++++++++++++++++++--------- terraform/variables.tf | 4 +- 4 files changed, 94 insertions(+), 63 deletions(-) diff --git a/roles/workload-agent/tasks/main.yml b/roles/workload-agent/tasks/main.yml index d1fb0f4b1..5ee85ebdb 100644 --- a/roles/workload-agent/tasks/main.yml +++ b/roles/workload-agent/tasks/main.yml @@ -21,10 +21,11 @@ - name: Create a database user for the Google Cloud Agent for Compute Workloads to collect Oracle metrics include_tasks: file: create_db_user.yml + # Run for single-instance setup or for the primary in multi-node Data Guard setup. + # For standby setup, install-oracle.sh generates the [primary] group in the inventory file. when: - oracle_metrics_secret | length > 0 - - "'primary1' in group_names" - tags: workload-agent + - "groups['primary'] is not defined" - name: Copy workload-agent's configuration file to the database VM template: diff --git a/terraform/main.tf b/terraform/main.tf index b0fb1f8a1..32c99eb4c 100644 --- a/terraform/main.tf +++ b/terraform/main.tf @@ -16,7 +16,6 @@ locals { fs_disks = [ { auto_delete = true - boot = false device_name = "oracle_home" disk_size_gb = var.oracle_home_disk.size_gb disk_type = var.oracle_home_disk.type @@ -26,7 +25,6 @@ locals { asm_disks = [ { auto_delete = true - boot = false device_name = "data" disk_size_gb = var.data_disk.size_gb disk_type = var.data_disk.type @@ -34,7 +32,6 @@ locals { }, { auto_delete = true - boot = false device_name = "reco" disk_size_gb = var.reco_disk.size_gb disk_type = var.reco_disk.type @@ -42,7 +39,6 @@ locals { }, { auto_delete = true - boot = false device_name = "swap" disk_size_gb = var.swap_disk_size_gb disk_type = var.swap_disk_type @@ -85,21 +81,21 @@ locals { ) instances = local.is_multi_instance ? { - node1 = { - name = "${var.instance_name}-1" - zone = var.zone1 + "${var.instance_name}-1" = { + zone = var.zone1 subnetwork = var.subnetwork1 + role = "primary" } - node2 = { - name = "${var.instance_name}-2" - zone = var.zone2 + "${var.instance_name}-2" = { + zone = var.zone2 subnetwork = var.subnetwork2 + role = "standby" } } : { - default = { - name = var.instance_name - zone = var.zone1 + "${var.instance_name}-1" = { + zone = var.zone1 subnetwork = var.subnetwork1 + role = "primary" } } } @@ -123,7 +119,7 @@ resource "google_compute_instance_template" "default" { network_interface { # gets overridden during instance creation - network = "default" + subnetwork = var.subnetwork1 } disk { boot = true @@ -136,12 +132,12 @@ resource "google_compute_instance_template" "default" { dynamic "disk" { for_each = local.additional_disks content { - auto_delete = lookup(disk.value, "auto_delete", null) - boot = lookup(disk.value, "boot", null) - device_name = lookup(disk.value, "device_name", null) - disk_size_gb = lookup(disk.value, "disk_size_gb", null) - disk_type = lookup(disk.value, "disk_type", null) - labels = lookup(disk.value, "disk_labels", null) + boot = false + auto_delete = disk.value.auto_delete + device_name = disk.value.device_name + disk_size_gb = disk.value.disk_size_gb + disk_type = disk.value.disk_type + labels = disk.value.disk_labels } } @@ -161,7 +157,7 @@ resource "google_compute_instance_template" "default" { resource "google_compute_instance_from_template" "database_vm" { for_each = local.instances - name = each.value.name + name = each.key zone = each.value.zone project = var.project_id source_instance_template = google_compute_instance_template.default.self_link @@ -182,11 +178,12 @@ resource "random_id" "suffix" { } locals { - oracle_nodes = [ + database_vm_nodes = [ for vm in google_compute_instance_from_template.database_vm : { name = vm.name zone = vm.zone - ip = vm.network_interface[0].network_ip + ip = vm.network_interface[0].network_ip + role = local.instances[vm.name].role } ] } @@ -212,7 +209,7 @@ locals { var.skip_database_config ? "--skip-database-config" : "", var.ora_pga_target_mb != "" ? "--ora-pga-target-mb ${var.ora_pga_target_mb}" : "", var.ora_sga_target_mb != "" ? "--ora-sga-target-mb ${var.ora_pga_target_mb}": "", - var.data_guard_protection_mode != "" ? "--data-guard-protection-mode ${var.data_guard_protection_mode}": "" + var.data_guard_protection_mode != "" ? "--data-guard-protection-mode '${var.data_guard_protection_mode}'": "" ])) } @@ -252,10 +249,9 @@ resource "google_compute_instance" "control_node" { metadata_startup_script = templatefile("${path.module}/scripts/setup.sh.tpl", { gcs_source = var.gcs_source - oracle_nodes_json = jsonencode(local.oracle_nodes) + database_vm_nodes_json = jsonencode(local.database_vm_nodes) common_flags = local.common_flags deployment_name = var.deployment_name - data_guard_protection_mode = var.data_guard_protection_mode }) metadata = { diff --git a/terraform/scripts/setup.sh.tpl b/terraform/scripts/setup.sh.tpl index a0969f285..680609ec8 100644 --- a/terraform/scripts/setup.sh.tpl +++ b/terraform/scripts/setup.sh.tpl @@ -1,7 +1,5 @@ #!/bin/bash -set -Eeuo pipefail - control_node_name="$(curl -s http://metadata.google.internal/computeMetadata/v1/instance/name -H 'Metadata-Flavor: Google')" # The zone value from the metadata server is in the format 'projects/PROJECT_NUMBER/zones/ZONE'. # extracting the last part @@ -47,6 +45,31 @@ EOF done } +send_ansible_completion_status() { + exit_code=$1 + if [[ $exit_code -eq 0 ]]; then + state="ansible_completed_success" + else + state="ansible_completed_failure" + fi + + timestamp=$(date --rfc-3339=seconds) + payload=$(cat < 1 ]]; then - # extract the IP of the VM whose name ends with "-1" - primary_ip="$(echo '${oracle_nodes_json}' | jq -r '.[] | select(.name | endswith("-1")) | .ip')" + primary_ip="$(echo '${database_vm_nodes_json}' | jq -r '.[] | select(.role == "primary") | .ip')" if [[ -z "$primary_ip" ]]; then - echo "ERROR: Could not find a primary node ending with '-1'." + echo "ERROR: Could not find a primary node with role 'primary'." exit 1 fi echo "PRIMARY_IP: $primary_ip" @@ -97,7 +120,8 @@ EOF export DEPLOYMENT_NAME="${deployment_name}" -for node in $(echo '${oracle_nodes_json}' | jq -c '.[]'); do +ssh_user="" +for node in $(echo '${database_vm_nodes_json}' | jq -c '.[] | select(.role == "primary")'); do node_name="$(echo "$node" | jq -r '.name')" node_ip="$(echo "$node" | jq -r '.ip')" node_zone="$(echo "$node" | jq -r '.zone')" @@ -122,15 +146,40 @@ for node in $(echo '${oracle_nodes_json}' | jq -c '.[]'); do exit 1 fi - if [[ "$num_nodes" -eq 1 || "$node_name" == *"-1" ]]; then - echo "Configuring PRIMARY or SINGLE instance: $node_name, IP: $node_ip, Zone: $node_zone" + echo "Configuring PRIMARY node: $node_name, IP: $node_ip, Zone: $node_zone" bash install-oracle.sh \ --cluster-type NONE \ --instance-ip-addr "$node_ip" \ --instance-ssh-user "$ssh_user" \ --instance-ssh-key /root/.ssh/google_compute_engine \ ${common_flags} - elif [[ "$node_name" == *"-2" ]]; then + + exit_code=$? + + if [[ $exit_code -ne 0 ]]; then + echo "Error: Primary setup failed for $node_name. Exiting." + send_ansible_completion_status $exit_code + exit 1 + fi +done + + +if [[ "$num_nodes" -gt 1 ]]; then + for node in $(echo '${database_vm_nodes_json}' | jq -c '.[] | select(.role == "standby")'); do + node_name="$(echo "$node" | jq -r '.name')" + node_ip="$(echo "$node" | jq -r '.ip')" + node_zone="$(echo "$node" | jq -r '.zone')" + + echo "Verifying primary node is reachable at $primary_ip..." + + if ping -c 3 "$primary_ip"; then + echo "Primary node is reachable. Proceeding with standby setup." + else + echo "Error: Primary node $primary_ip is not reachable. Cannot continue with standby setup." + send_ansible_completion_status 1 + exit 1 + fi + echo "Configuring STANDBY node: $node_name, IP: $node_ip, Zone: $node_zone" bash install-oracle.sh \ --cluster-type DG \ @@ -139,29 +188,14 @@ for node in $(echo '${oracle_nodes_json}' | jq -c '.[]'); do --instance-ssh-user "$ssh_user" \ --instance-ssh-key /root/.ssh/google_compute_engine \ ${common_flags} - fi -done - -if [[ $? -eq 0 ]]; then - state="ansible_completed_success" -else - state="ansible_completed_failure" + exit_code=$? + if [[ $exit_code -ne 0 ]]; then + echo "Error: Standby setup failed for $node_name. Exiting." + send_ansible_completion_status $exit_code + exit $exit_code + fi + done fi -timestamp=$(date --rfc-3339=seconds) -payload=$(cat < Date: Wed, 16 Jul 2025 12:59:44 -0700 Subject: [PATCH 10/12] add newline to setup.sh.tpl and remove traling spaces from main.tf --- terraform/main.tf | 2 -- terraform/scripts/setup.sh.tpl | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/terraform/main.tf b/terraform/main.tf index 32c99eb4c..8b75adb16 100644 --- a/terraform/main.tf +++ b/terraform/main.tf @@ -100,7 +100,6 @@ locals { } } - data "google_compute_image" "os_image" { family = var.source_image_family project = var.source_image_project @@ -265,4 +264,3 @@ output "control_node_log_url" { description = "Logs Explorer URL with Oracle Toolkit output" value = "https://console.cloud.google.com/logs/query;query=resource.labels.instance_id%3D${urlencode(google_compute_instance.control_node.instance_id)};duration=P30D?project=${urlencode(var.project_id)}" } - diff --git a/terraform/scripts/setup.sh.tpl b/terraform/scripts/setup.sh.tpl index 680609ec8..f182c6a31 100644 --- a/terraform/scripts/setup.sh.tpl +++ b/terraform/scripts/setup.sh.tpl @@ -198,4 +198,4 @@ if [[ "$num_nodes" -gt 1 ]]; then done fi -send_ansible_completion_status 0 \ No newline at end of file +send_ansible_completion_status 0 From 29d51cad92677165aa67ea761c6f848834ae1814 Mon Sep 17 00:00:00 2001 From: Alexey Basinov Date: Wed, 16 Jul 2025 13:32:46 -0700 Subject: [PATCH 11/12] Use -gt for numeric comparison and exit with actual exit code --- terraform/scripts/setup.sh.tpl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/terraform/scripts/setup.sh.tpl b/terraform/scripts/setup.sh.tpl index f182c6a31..bb8226062 100644 --- a/terraform/scripts/setup.sh.tpl +++ b/terraform/scripts/setup.sh.tpl @@ -96,7 +96,7 @@ num_nodes="$(echo '${database_vm_nodes_json}' | jq "length")" echo "num_nodes=$num_nodes" primary_ip="" -if [[ "$num_nodes" > 1 ]]; then +if [[ "$num_nodes" -gt 1 ]]; then primary_ip="$(echo '${database_vm_nodes_json}' | jq -r '.[] | select(.role == "primary") | .ip')" if [[ -z "$primary_ip" ]]; then echo "ERROR: Could not find a primary node with role 'primary'." @@ -159,7 +159,7 @@ for node in $(echo '${database_vm_nodes_json}' | jq -c '.[] | select(.role == "p if [[ $exit_code -ne 0 ]]; then echo "Error: Primary setup failed for $node_name. Exiting." send_ansible_completion_status $exit_code - exit 1 + exit $exit_code fi done From 4b5b1d632fa19f2c21d12c4cd3cedb11968bbbae Mon Sep 17 00:00:00 2001 From: Alexey Basinov Date: Thu, 17 Jul 2025 10:25:11 -0700 Subject: [PATCH 12/12] output database VM names and also update terraform.md --- docs/terraform.md | 25 ++++++++++++++++++++++++- terraform/main.tf | 5 +++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/docs/terraform.md b/docs/terraform.md index cb8a8280b..08134a71f 100644 --- a/docs/terraform.md +++ b/docs/terraform.md @@ -20,7 +20,14 @@ This setup supports the deployment of the following configurations: This approach is particularly suitable for deploying and configuring: - Oracle databases on RHEL or Oracle Linux -- High-availability configurations for database and application clusters +- Two-node Oracle Data Guard deployments, where: + + The user provides: + + * zone1 and subnetwork1 for the primary node + * zone2 and subnetwork2 for the standby node + + Only 2-node Data Guard setups are currently supported. --- @@ -43,6 +50,22 @@ This infrastructure is modular and customizable, allowing you to tailor it to sp --- +## Instance naming convention +For single-instance deployments, the VM will be named using the pattern: +"-1". +Example: If instance_name = "oracle-db", the resulting VM will be oracle-db-1. + +For multi-node Oracle Data Guard deployments: + +* Primary node: "-1" +* Standby node: "-2" + +Example: If instance_name = "oracle-db", the primary VM will be oracle-db-1 and the standby VM will be oracle-db-2. + + +--- + + ## Pre-requisites To use this Terraform and Ansible integration, ensure you have the following tools installed: diff --git a/terraform/main.tf b/terraform/main.tf index 8b75adb16..89bd43032 100644 --- a/terraform/main.tf +++ b/terraform/main.tf @@ -264,3 +264,8 @@ output "control_node_log_url" { description = "Logs Explorer URL with Oracle Toolkit output" value = "https://console.cloud.google.com/logs/query;query=resource.labels.instance_id%3D${urlencode(google_compute_instance.control_node.instance_id)};duration=P30D?project=${urlencode(var.project_id)}" } + +output "database_vm_names" { + description = "Names of the created database VMs from instance templates" + value = [for vm in google_compute_instance_from_template.database_vm : vm.name] +}