Skip to content

microsoftexpert/tf-mod-gcp-spanner-instance

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

☁️ Google Cloud Spanner Instance Terraform Module

Provisions a single Cloud Spanner instance (google_spanner_instance) together with its for_each-managed databases (google_spanner_database) as one coherent unit. Targets hashicorp/google ~> 7.0, Terraform >= 1.12.0.

Terraform Provider Version Type Resources Posture


🧩 Overview

  • 🗄️ Provisions one Cloud Spanner instance (google_spanner_instance.this) — the isolated set of compute resources on which databases are hosted.
  • 🧬 Manages any number of databases (google_spanner_database.databases) via for_each over var.databases, keyed by database name.
  • ⚖️ Enforces Cloud Spanner's three-way capacity mutual exclusion — exactly one of num_nodes, processing_units, or autoscaling_config (unless instance_type = "FREE_INSTANCE") — at plan time via a cross-variable validation {} block, not left to an apply-time API rejection.
  • 💰 Never defaults instance capacity. Compute cost is real and immediate the instant a Spanner instance exists — the caller must state a capacity choice explicitly.
  • 🛡️ deletion_policy = "PREVENT" on the instance (the closest available guard — the instance has no deletion_protection boolean at all) and both deletion_protection = true and enable_drop_protection = true on every database by default.
  • 📝 Supports the Cloud Spanner ddl field's append-only update semantics with an explicit warning against in-place edits that would force database recreation.
  • 🔒 Per-database CMEK (encryption_config) accepted as an optional variable, never defaulted to a specific key.

💡 Why it matters: Cloud Spanner is our horizontally-scalable, strongly-consistent relational data store for workloads that outgrow Cloud SQL's single-instance ceiling. Because compute capacity billing starts the moment the instance exists — there is no free-tier default this module can safely fall back to for production use — this module treats an explicit capacity choice as a mandatory input, not an optional one, the same way it treats CMEK as an explicit, never-defaulted opt-in.


❤️ Support this project

If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:

Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!


🗺️ Where this fits

This module sits in the Data domain alongside tf-mod-gcp-cloud-sql-instance, tf-mod-gcp-bigquery-dataset, tf-mod-gcp-pubsub-topic, and tf-mod-gcp-bigtable-instance. It optionally consumes tf-mod-gcp-kms-keyring's crypto key id for per-database CMEK — no other module in the current catalog has an identified dependency on this module's outputs.

flowchart LR
 kms["tf-mod-gcp-kms-keyring"]:::upstream
 kms -->|"optional CMEK key"| spanner

 subgraph domain["Data domain"]
 direction LR
 spanner["tf-mod-gcp-spanner-instance"]:::self
 sql["tf-mod-gcp-cloud-sql-instance"]:::sibling
 bq["tf-mod-gcp-bigquery-dataset"]:::sibling
 pubsub["tf-mod-gcp-pubsub-topic"]:::sibling
 bigtable["tf-mod-gcp-bigtable-instance"]:::sibling
 end

 classDef self fill:#4285F4,color:#FFFFFF,stroke:#174EA6,stroke-width:2px;
 classDef sibling fill:#ECEFF1,color:#263238,stroke:#90A4AE,stroke-width:1px;
 classDef upstream fill:#174EA6,color:#FFFFFF,stroke:#0D47A1,stroke-width:2px;
Loading

Validated via the Mermaid Chart MCP (validate_and_render_mermaid_diagram) before embedding.


🧬 What this builds

Unlike tf-mod-gcp-bigtable-instance's sibling module, Cloud Spanner has no separate cluster-like nested resource between the instance and its databases — the instance itself is the compute/replication unit, and databases attach to it directly. Neither resource in this module exposes a self_link attribute (verified against the live schema).

flowchart TB
 subgraph Module["tf-mod-gcp-spanner-instance"]
 direction TB
 inst["google_spanner_instance.this<br/>(keystone)"]:::keystone
 asc["autoscaling_config (dynamic)<br/>mutually exclusive w/ num_nodes/processing_units"]:::nested
 instto["timeouts (dynamic)<br/>create/update/delete"]:::nested
 db["google_spanner_database.databases<br/>for_each var.databases"]:::child
 enc["encryption_config (dynamic)<br/>optional per-database CMEK"]:::nested
 dbto["timeouts (dynamic)<br/>create/update/delete"]:::nested
 end

 inst -->|"instance = google_spanner_instance.this.name"| db
 inst --> asc
 inst --> instto
 db --> enc
 db --> dbto

 classDef keystone fill:#174EA6,color:#FFFFFF,stroke:#0D47A1,stroke-width:2px;
 classDef child fill:#4285F4,color:#FFFFFF,stroke:#174EA6,stroke-width:2px;
 classDef nested fill:#ECEFF1,color:#263238,stroke:#90A4AE,stroke-width:1px;
Loading

Validated via the Mermaid Chart MCP before embedding.

Resource inventory (2 resource types):

Resource Count Role
google_spanner_instance.this 1 Keystone — the isolated compute/replication unit databases attach to
google_spanner_database.databases for_each over var.databases Databases hosted on the instance, each with its own DDL, CMEK, and deletion-guard configuration

✅ Provider / Versions

Requirement Value
Terraform >= 1.12.0
hashicorp/google ~> 7.0
Provider block None — the caller's root module configures google (ADC, WIF, or a service-account key per our authentication model)

Schema notes that bite:

  • Capacity is a three-way mutual exclusion, not two. Confirmed live Argument Reference: "Exactly one of either num_nodes, processing_units or autoscaling_config must be present in terraform except when instance_type = FREE_INSTANCE." Enforced by this module's cross-variable validation {} on autoscaling_config.
  • google_spanner_instance has no deletion_protection boolean at all. The only destroy-related fields are deletion_policy (provider default "DELETE") and force_destroy.
  • google_spanner_database has three independent deletion mechanismsdeletion_protection, enable_drop_protection, and deletion_policy — not one. See 🧠 Architecture Notes for the difference between the first two; they are not redundant.
  • ddl is append-only-safe, not general ALTER semantics. Newly appended statements can be applied in an update; editing or removing a prior statement forces database recreation. Terraform performs no drift detection on this field.
  • Neither resource exposes a self_link attribute. Confirmed absent from both Attributes References — this module's outputs deliberately omit a self_link row.
  • CMEK (encryption_config) lives only on the database, never the instance — confirmed absent from the instance's Argument Reference entirely.
  • The database's instance argument wants the short instance name, not .id. Confirmed by the provider's own Example Usage (instance = google_spanner_instance.main.name) — this module's main.tf deliberately deviates from the library's general "prefer .id" guidance for this specific cross-reference.
  • Instance name is optional (unlike most GCP resources in this library) — a random tf--prefixed name is generated if omitted. Force-new once set, per the house blanket rule.

🔑 Required IAM Roles

  • roles/spanner.admin — full lifecycle management (create/update/delete) of Spanner instances and databases on the target project. This is the only role confirmed for this module.

(Sourced directly from SCOPE.md — not re-derived.)


☁️ GCP Prerequisites

  • spanner.googleapis.com API enabled on the target project (via tf-mod-gcp-project-services, applied before this module per the house recommended authoring order).
  • Instance config is force-new and effectively permanent — there is no in-place "move" between configs. If the organization has constraints/gcp.resourceLocations set, it restricts which config values are permitted; a violation surfaces only at apply, never at plan.
  • Cloud Spanner enforces per-project, per-instance-config node/processing-unit quotas — invisible to terraform plan; verify current quota before a capacity request would exceed it.
  • Compute-capacity cost is real and immediate. Unlike most modules in this library, an "empty call" cannot produce a genuinely free/inert Spanner instance except via the narrow instance_type = "FREE_INSTANCE" tier (one per project/org, cannot combine with edition, and too limited for most production workloads). This module deliberately never defaults num_nodes/processing_units/autoscaling_config.

(Sourced directly from SCOPE.md — not re-derived.)


📁 Module Structure

tf-mod-gcp-spanner-instance/
├── providers.tf # required_providers (hashicorp/google ~> 7.0) + required_version — no provider {} block
├── variables.tf # instance args, three-way capacity validation, databases map(object(...))
├── main.tf # google_spanner_instance.this + google_spanner_database.databases (for_each)
├── outputs.tf # id, name, database_ids, database_names — no self_link (see Architecture Notes)
├── README.md # this file
├── SCOPE.md # cross-module contract
└── examples/ # runnable example(s) matching the Quick Start below

⚙️ Quick Start

# Caller's root module configures the google provider (ADC, WIF, or a service-account key) —
# this module never declares project/region/zone/credentials variables.

module "spanner_instance" {
  source = "git::https://github.com/microsoftexpert/tf-mod-gcp-spanner-instance.git?ref=v1.0.0"

  config       = "regional-us-central1"
  display_name = "Primary Spanner Instance"
  num_nodes    = 1

  databases = {
    "app-primary-db" = {
      ddl = [
        "CREATE TABLE Users (UserId INT64 NOT NULL, Name STRING(MAX)) PRIMARY KEY (UserId)",
      ]
    }
  }
}

🔌 Cross-Module Contract

Consumes

Input Type Source module
CMEK crypto key name (per-database encryption_config.kms_key_name) — optional string (full KMS crypto key resource path) tf-mod-gcp-kms-keyring
(none required) — runs standalone with the default no-CMEK, GOOGLE_STANDARD_SQL configuration

Emits

Output Description Consumed by
id Instance resource id, format {{project}}/{{name}} None identified yet in the current catalog
name Instance name (as supplied, or the provider's random tf--prefixed name if unset) None identified yet
database_ids Map of database key → google_spanner_database.id (format {{instance}}/{{name}}) None identified yet
database_names Map of database key → database name None identified yet

ℹ️ No self_link row — neither google_spanner_instance nor google_spanner_database exposes a self_link attribute. This is a deliberate, documented deviation from the library's default "id then self_link" ordering, not an omission — same precedent as tf-mod-gcp-kms-keyring.


📚 Example Library

1 · Minimal instance sized with num_nodes
module "spanner_instance" {
  source = "git::https://github.com/microsoftexpert/tf-mod-gcp-spanner-instance.git?ref=v1.0.0"

  config       = "regional-us-central1"
  display_name = "Minimal Node-Sized Instance"
  num_nodes    = 1
}

ℹ️ databases defaults to {} — an instance with no databases is a valid, safe starting point for a composition that adds databases in a later apply.

2 · Sizing by processing_units instead of num_nodes
module "spanner_instance" {
  source = "git::https://github.com/microsoftexpert/tf-mod-gcp-spanner-instance.git?ref=v1.0.0"

  config           = "regional-us-central1"
  display_name     = "Processing-Unit-Sized Instance"
  processing_units = 200
}

💡 processing_units and num_nodes are two of the three mutually exclusive capacity mechanisms (1000 processing units == 1 node) — set exactly one, never both.

3 · Autoscaling by processing-unit limits
module "spanner_instance" {
  source = "git::https://github.com/microsoftexpert/tf-mod-gcp-spanner-instance.git?ref=v1.0.0"

  config       = "regional-us-central1"
  display_name = "Autoscaled Instance"

  autoscaling_config = {
    autoscaling_limits = {
      min_processing_units = 1000
      max_processing_units = 4000
    }
    autoscaling_targets = {
      high_priority_cpu_utilization_percent = 65
      storage_utilization_percent           = 75
    }
  }
}

⚠️ autoscaling_config is the third mutually exclusive capacity mechanism — setting it together with num_nodes or processing_units fails this module's plan-time validation {} rather than surfacing only as an apply-time API error.

4 · Free-tier instance (no capacity variable required)
module "spanner_instance" {
  source = "git::https://github.com/microsoftexpert/tf-mod-gcp-spanner-instance.git?ref=v1.0.0"

  config                       = "regional-us-central1"
  display_name                 = "Free Tier Instance"
  instance_type                = "FREE_INSTANCE"
  default_backup_schedule_type = "NONE"
}

🔒 instance_type = "FREE_INSTANCE" is the sole exception to the three-way capacity mutual exclusion — none of num_nodes / processing_units / autoscaling_config need be set. edition must remain unset, and default_backup_schedule_type must not be "AUTOMATIC" (free instances do not support backups) — both enforced by this module's validations.

5 · Single database with default settings
module "spanner_instance" {
  source = "git::https://github.com/microsoftexpert/tf-mod-gcp-spanner-instance.git?ref=v1.0.0"

  config       = "regional-us-central1"
  display_name = "Instance With One Database"
  num_nodes    = 1

  databases = {
    "app-primary-db" = {}
  }
}

💡 An empty database entry still produces a complete, valid database: database_dialect = "GOOGLE_STANDARD_SQL", deletion_protection = true, and enable_drop_protection = true all apply by default.

6 · Database created with an initial DDL schema
module "spanner_instance" {
  source = "git::https://github.com/microsoftexpert/tf-mod-gcp-spanner-instance.git?ref=v1.0.0"

  config       = "regional-us-central1"
  display_name = "Instance With Schema"
  num_nodes    = 1

  databases = {
    "orders-db" = {
      ddl = [
        "CREATE TABLE Orders (OrderId INT64 NOT NULL, CustomerId INT64 NOT NULL) PRIMARY KEY (OrderId)",
        "CREATE INDEX OrdersByCustomer ON Orders(CustomerId)",
      ]
    }
  }
}

ℹ️ These statements execute atomically with database creation — if any statement errors, the database is not created.

7 · ⚠️ Appending new DDL to an existing database (append-only pattern)
module "spanner_instance" {
  source = "git::https://github.com/microsoftexpert/tf-mod-gcp-spanner-instance.git?ref=v1.0.0"

  config       = "regional-us-central1"
  display_name = "Instance With Schema"
  num_nodes    = 1

  databases = {
    "orders-db" = {
      ddl = [
        "CREATE TABLE Orders (OrderId INT64 NOT NULL, CustomerId INT64 NOT NULL) PRIMARY KEY (OrderId)",
        "CREATE INDEX OrdersByCustomer ON Orders(CustomerId)",
        # Appended in a later apply — safe, executes as an in-place update:
        "CREATE TABLE OrderLines (OrderId INT64 NOT NULL, LineNo INT64 NOT NULL, Sku STRING(64)) PRIMARY KEY (OrderId, LineNo), INTERLEAVE IN PARENT Orders ON DELETE CASCADE",
      ]
    }
  }
}

⚠️ Only append. Editing or removing either of the first two statements above — instead of only adding the third — creates a plan that marks the database for recreation, not an in-place ALTER. Terraform performs no drift detection on ddl and trusts whatever is recorded in state. With enable_drop_protection = true (this module's default), that recreation plan is additionally blocked outright at apply until the guard is explicitly disabled.

8 · PostgreSQL-dialect database
module "spanner_instance" {
  source = "git::https://github.com/microsoftexpert/tf-mod-gcp-spanner-instance.git?ref=v1.0.0"

  config       = "regional-us-central1"
  display_name = "PostgreSQL Dialect Instance"
  num_nodes    = 1

  databases = {
    "pg-app-db" = {
      database_dialect = "POSTGRESQL"
      ddl = [
        "CREATE TABLE users (user_id BIGINT NOT NULL PRIMARY KEY, name TEXT)",
      ]
    }
  }
}

ℹ️ database_dialect defaults to "GOOGLE_STANDARD_SQL" — set "POSTGRESQL" explicitly to opt into PostgreSQL-compatible DDL/SQL syntax for this database only; other databases on the same instance may use either dialect independently.

9 · Per-database CMEK (single-region key)
module "spanner_instance" {
  source = "git::https://github.com/microsoftexpert/tf-mod-gcp-spanner-instance.git?ref=v1.0.0"

  config       = "regional-us-central1"
  display_name = "CMEK-Encrypted Instance"
  num_nodes    = 1

  databases = {
    "regulated-data-db" = {
      encryption_config = {
        kms_key_name = "projects/casey-prod-security/locations/us-central1/keyRings/casey-prod-cmek/cryptoKeys/spanner-db-key"
      }
    }
  }
}

🔒 CMEK is database-level only — google_spanner_instance has no encryption argument at all. Never defaulted to a specific key by this module; the caller supplies a fully-qualified crypto key resource name, typically sourced from tf-mod-gcp-kms-keyring's crypto_key_ids output (see the end-to-end composition below).

10 · Multi-region instance with multi-region CMEK
module "spanner_instance" {
  source = "git::https://github.com/microsoftexpert/tf-mod-gcp-spanner-instance.git?ref=v1.0.0"

  config       = "nam-eur-asia1"
  display_name = "Multi-Region Instance"
  num_nodes    = 2

  databases = {
    "global-catalog-db" = {
      encryption_config = {
        kms_key_names = [
          "projects/casey-prod-security/locations/us-central1/keyRings/casey-prod-cmek/cryptoKeys/spanner-db-key",
          "projects/casey-prod-security/locations/europe-west1/keyRings/casey-prod-cmek/cryptoKeys/spanner-db-key",
          "projects/casey-prod-security/locations/asia-east1/keyRings/casey-prod-cmek/cryptoKeys/spanner-db-key",
        ]
      }
    }
  }
}

ℹ️ kms_key_names (plural) supplies one key per replica region for a multi-region instance config; kms_key_name (singular, example 9) applies to a single-region instance. This module does not validate the two as mutually exclusive — an invalid combination surfaces at apply, not plan (see Schema notes).

11 · Custom version_retention_period for a longer stale-read window
module "spanner_instance" {
  source = "git::https://github.com/microsoftexpert/tf-mod-gcp-spanner-instance.git?ref=v1.0.0"

  config       = "regional-us-central1"
  display_name = "Extended Retention Instance"
  num_nodes    = 1

  databases = {
    "audit-db" = {
      version_retention_period = "3d"
    }
  }
}

⚠️ If set, avoid adding new ddl statements in a later apply that themselves change version_retention_period — the provider's own docs flag this as a self-referential trap. This module validates only the "<number><d|h|m|s>" format, not the confirmed 1-hour–7-day numeric range (which spans mixed units); an out-of-range value is caught at apply.

12 · Opting out of both deletion guards for a genuinely disposable dev database
module "spanner_instance" {
  source = "git::https://github.com/microsoftexpert/tf-mod-gcp-spanner-instance.git?ref=v1.0.0"

  config       = "regional-us-central1"
  display_name = "Dev Sandbox Instance"
  num_nodes    = 1

  databases = {
    "scratch-db" = {
      deletion_protection    = false
      enable_drop_protection = false
    }
  }
}

⚠️ Both guards default to true. Setting both to false restores the ability to terraform destroy this database through Terraform AND through every other interface (console, gcloud, API) — only appropriate for a database that never holds data anyone needs to recover. Leaving either guard at its default blocks the destroy outright (see 🔍 Troubleshooting).

13 · Asymmetric autoscaling for a specific replica
module "spanner_instance" {
  source = "git::https://github.com/microsoftexpert/tf-mod-gcp-spanner-instance.git?ref=v1.0.0"

  config       = "nam-eur-asia1"
  display_name = "Asymmetric Autoscaling Instance"

  autoscaling_config = {
    autoscaling_limits = {
      min_nodes = 3
      max_nodes = 9
    }
    asymmetric_autoscaling_options = [
      {
        replica_selection = { location = "us-central1" }
        overrides = {
          autoscaling_limits            = { min_nodes = 2, max_nodes = 6 }
          disable_total_cpu_autoscaling = false
        }
      }
    ]
  }
}

ℹ️ asymmetric_autoscaling_options lets one replica scale within its own limits, independent of the top-level autoscaling_limits applied to the rest of the instance's replicas — useful when one region carries disproportionate read traffic.

14 · Custom create/update/delete timeouts (instance and database)
module "spanner_instance" {
  source = "git::https://github.com/microsoftexpert/tf-mod-gcp-spanner-instance.git?ref=v1.0.0"

  config       = "regional-us-central1"
  display_name = "Slow-Provisioning Instance"
  num_nodes    = 1

  timeouts = {
    create = "40m"
    update = "40m"
    delete = "40m"
  }

  databases = {
    "large-import-db" = {
      timeouts = {
        create = "40m"
        update = "40m"
        delete = "40m"
      }
    }
  }
}

ℹ️ Both resources support all three timeout operations, each defaulting to 20 minutes — verified independently against the live schema for the instance and the database.

15 · 🏗️ End-to-end composition

Wires an optional tf-mod-gcp-kms-keyring crypto key into this module's per-database encryption_config.kms_key_name, and this module's database_ids output toward a hypothetical downstream consumer — no consumer is yet identified in the current catalog, consistent with SCOPE.md's Emits table.

module "kms_keyring" {
  source = "git::https://github.com/microsoftexpert/tf-mod-gcp-kms-keyring.git?ref=v1.0.0"

  key_ring_name = "casey-prod-cmek"
  location      = "us-central1"

  crypto_keys = {
    "spanner-db-key" = {
      purpose = "ENCRYPT_DECRYPT"
      labels = {
        team = "data-platform"
      }
    }
  }
}

module "spanner_instance" {
  source = "git::https://github.com/microsoftexpert/tf-mod-gcp-spanner-instance.git?ref=v1.0.0"

  config       = "regional-us-central1"
  display_name = "Production Spanner Instance"
  num_nodes    = 3

  labels = {
    team        = "data-platform"
    environment = "prod"
  }

  databases = {
    "app-primary-db" = {
      ddl = [
        "CREATE TABLE Users (UserId INT64 NOT NULL, Name STRING(MAX)) PRIMARY KEY (UserId)",
      ]

      # Consumes tf-mod-gcp-kms-keyring's crypto_key_ids output (Emits table row:
      # `crypto_key_ids`) — never a hardcoded key resource name.
      encryption_config = {
        kms_key_name = module.kms_keyring.crypto_key_ids["spanner-db-key"]
      }
    }
  }
}

# Hypothetical downstream consumer — no such module exists in the current catalog yet.
# output "app_primary_database_id" {
# value = module.spanner_instance.database_ids["app-primary-db"]
# }

⚠️ Before this composition can succeed, the Cloud Spanner service agent for the target project must be granted roles/cloudkms.cryptoKeyEncrypterDecrypter directly on module.kms_keyring.crypto_key_ids["spanner-db-key"] — a binding out of scope for both modules by design. Grant it via a dedicated IAM aggregation module or the composing root module, applied after the crypto key exists and before this module's database is created; allow for IAM propagation lag (up to ~60 seconds) between the grant and the database apply.


📥 Inputs

Variable Type Default Notes
name string null Optional; force-new; random tf- name if unset
config string — (required) Force-new; not validated against a hardcoded list
display_name string — (required) 4-30 characters
num_nodes number null One of three mutually exclusive capacity mechanisms
processing_units number null One of three mutually exclusive capacity mechanisms
autoscaling_config object({...}) null One of three mutually exclusive capacity mechanisms
edition string null Must be unset when instance_type = "FREE_INSTANCE"
instance_type string "PROVISIONED" "PROVISIONED" | "FREE_INSTANCE"
default_backup_schedule_type string "AUTOMATIC" Secure-default extension; must be "NONE" for FREE_INSTANCE
deletion_policy string "PREVENT" Secure-default extension (no deletion_protection field exists)
force_destroy bool false Deletes all backups on instance destroy when true
labels map(string) {} Instance-level only — database has no labels surface
timeouts object({...}) null Instance create/update/delete, 20m provider default each
databases map(object({...})) {} Keyed by database name — see full schema below
Full autoscaling_config object schema
variable "autoscaling_config" {
  type = object({
    autoscaling_limits = optional(object({
      min_processing_units = optional(number)
      max_processing_units = optional(number)
      min_nodes            = optional(number)
      max_nodes            = optional(number)
    }))
    autoscaling_targets = optional(object({
      high_priority_cpu_utilization_percent = optional(number)
      storage_utilization_percent           = optional(number)
      total_cpu_utilization_percent         = optional(number)
    }))
    asymmetric_autoscaling_options = optional(list(object({
      replica_selection = object({
        location = string
      })
      overrides = object({
        autoscaling_limits = optional(object({
          min_nodes            = optional(number)
          max_nodes            = optional(number)
          min_processing_units = optional(number)
          max_processing_units = optional(number)
        }))
        autoscaling_target_high_priority_cpu_utilization_percent = optional(number)
        autoscaling_target_total_cpu_utilization_percent         = optional(number)
        disable_high_priority_cpu_autoscaling                    = optional(bool)
        disable_total_cpu_autoscaling                            = optional(bool)
      })
    })), [])
  })
  default = null
}
Full databases object schema
variable "databases" {
  type = map(object({
    database_dialect = optional(string, "GOOGLE_STANDARD_SQL")
    ddl              = optional(list(string), [])
    encryption_config = optional(object({
      kms_key_name  = optional(string)
      kms_key_names = optional(list(string))
    }))
    version_retention_period = optional(string)
    default_time_zone        = optional(string)
    deletion_protection      = optional(bool, true)
    enable_drop_protection   = optional(bool, true)
    deletion_policy          = optional(string, "DELETE")
    timeouts = optional(object({
      create = optional(string)
      update = optional(string)
      delete = optional(string)
    }))
  }))
  default = {}
}

🧾 Outputs

Output Description Sensitive
id Instance Terraform-internal resource id ({{project}}/{{name}}) No
name Instance name No
database_ids Map of database key → database id ({{instance}}/{{name}}) No
database_names Map of database key → database name No

ℹ️ No self_link output — see 🔌 Cross-Module Contract and 🧠 Architecture Notes.


🧠 Architecture Notes

  • The instance has no deletion_protection boolean. Confirmed absent from the live schema — the only destroy-related fields on google_spanner_instance are deletion_policy (provider default "DELETE") and force_destroy. This module compensates by defaulting deletion_policy = "PREVENT", the closest available guard, rather than leaving the provider's own "DELETE" default in place.
  • enable_drop_protection and deletion_protection on the database are NOT redundant. deletion_protection (also defaulted true here) is a Terraform-state-only guard — it blocks terraform destroy/apply from deleting the database, but nothing stops deletion through the console, gcloud, or the API directly. enable_drop_protection (defaulted true here — the provider's own default is false) is the materially stronger, API-level guard: it blocks deletion through every interface, and it additionally blocks deletion of the parent instance while the database exists. A caller who wants to destroy an instance with databases still attached must disable enable_drop_protection on every database first — disabling only deletion_protection is not sufficient.
  • Instance capacity cost-safety rationale. Given Spanner compute capacity is real, ongoing cost the instant the instance exists, this module's "empty call produces the safe resource" principle cannot mean "produces a free resource" the way it does for most modules in this library — the closest analog is "produces no resource until the caller states a capacity choice explicitly," enforced via the cross-variable validation {} on autoscaling_config rather than a baked-in default for any of the three capacity fields.
  • ddl is append-only-safe, not general ALTER semantics. Terraform performs no drift detection on this field and trusts whatever is recorded in state; editing or removing a prior statement (rather than only appending) marks the database for recreation — see Example 7.
  • The database's instance argument takes the short instance name, not .id. Confirmed by the provider's own Example Usage — a deliberate deviation from this library's general "cross-reference via .id" guidance for this specific argument.
  • for_each key stability. Database map keys become the GCP resource name and the key used in database_ids/database_names. Renaming a key is a destroy/recreate (Terraform sees a removed + an added resource), not an in-place rename.
  • Implicit dependency ordering. google_spanner_database.databases references google_spanner_instance.this.name directly — no explicit depends_on between the two.
  • IAM propagation delay. This module grants no IAM itself, but a composition that grants roles/spanner.admin or a CMEK crypto-key-level role immediately before this module's apply may see a transient permission-denied error for up to ~60 seconds after the grant.

🧱 Design Principles

Concern Secure default Opt-out (explicit)
Instance destroy guard (no deletion_protection field exists) deletion_policy = "PREVENT" Caller sets "DELETE" or "ABANDON"
Database Terraform-only destroy guard deletion_protection = true Caller sets false
Database API-level, all-interfaces destroy guard (also blocks parent instance deletion) enable_drop_protection = true Caller sets false
Database destroy policy Left at provider default "DELETE" — the two booleans above already block deletion by default Caller sets "ABANDON" or "PREVENT" explicitly
Instance capacity (cost-safety, not a security control) Never defaulted — caller must set exactly one of num_nodes / processing_units / autoscaling_config (or none, for FREE_INSTANCE) N/A — a mandatory explicit choice by design
Default backup behavior for new databases default_backup_schedule_type = "AUTOMATIC" (extends the house Cloud SQL backups precedent to Spanner) Caller sets "NONE"; required for FREE_INSTANCE
Per-database CMEK Accepted as an optional variable, never defaulted to a specific key Caller supplies encryption_config.kms_key_name/kms_key_names explicitly
Empty call databases defaults to {} — an instance with no databases, the safe, inert result Caller supplies databases entries explicitly

🚀 Runbook

cd C:\GitHubCode\newgooglecloudmodules\tf-mod-gcp-spanner-instance
terraform init -backend=false
terraform validate
terraform fmt -check

Pin ?ref=v1.0.0 when consuming this module — never a branch. This library is plan-only; a human applies from CI with valid Workload Identity Federation or ADC credentials.


🧪 Testing

  • terraform init -backend=false, terraform validate, and terraform fmt -check are the entire offline proof gate for this module — all three pass cleanly as of this authoring session, against the actually-resolved hashicorp/google provider version 7.39.0.
  • validate/fmt confirm internal type/reference consistency and canonical formatting only. Neither can catch GCP API-level rejections (quota, org policy, an invalid config or version_retention_period range, IAM propagation) — those surface only at apply time, against a real project, from a human-run CI pipeline with valid credentials.
  • The examples/ directory exists so a consuming GitHub Actions workflow can run a real terraform plan against a real project as part of that pipeline's own review gate; this library only guarantees the example is syntactically and structurally sound in isolation.

💬 Example Output

$ terraform output

id = "casey-prod-data/casey-prod-spanner"
name = "casey-prod-spanner"
database_ids = {
 "app-primary-db" = "casey-prod-spanner/app-primary-db"
}
database_names = {
 "app-primary-db" = "app-primary-db"
}

🔍 Troubleshooting

Symptom Cause Fix
terraform apply shows a database recreation plan you did not expect after editing ddl A prior ddl list entry was edited or removed instead of only appending new statements — the provider marks the resource for recreation on any change to an existing statement Revert the edited/removed statement; add new schema changes as new, appended ddl list entries only
terraform destroy/apply fails with a drop-protection or deletion-protection error on a database enable_drop_protection and/or deletion_protection is still true (this module's default) Set the relevant guard(s) to false explicitly in a prior apply, then retry the destroy
terraform destroy on the instance fails even though the instance's own deletion_policy is not "PREVENT" A database attached to the instance still has enable_drop_protection = true — this guard also blocks parent-instance deletion Set enable_drop_protection = false on every database attached to the instance first, apply, then destroy the instance
Error: Exactly one of num_nodes, processing_units, or autoscaling_config must be set... at plan time More than one (or none) of the three capacity variables was supplied, and instance_type is not "FREE_INSTANCE" Set exactly one of the three; leave the other two at their default null
Error: edition must not be set when instance_type = "FREE_INSTANCE" edition and instance_type = "FREE_INSTANCE" were set together Remove edition for a free-tier instance
terraform plan shows no error but apply fails with an invalid config (instance configuration) value This library is plan-only — config is deliberately not validated against a hardcoded allow-list, and org policy (constraints/gcp.resourceLocations) is invisible to plan Confirm the config value against the current Cloud Spanner configurations reference and the org's constraints/gcp.resourceLocations policy before apply
apply fails with a quota-exceeded error despite a clean plan Cloud Spanner enforces per-project, per-instance-config node/processing-unit quotas invisible to terraform plan Verify current quota in the target project/instance config before increasing num_nodes/processing_units/autoscaling_config limits

🔗 Related Docs


💙 "Infrastructure as Code should be standardized, consistent, and secure."

Packages

 
 
 

Contributors

Languages