Hedronite · Ops Lesson · 01-Earth-DevOps / Terraform GCP Cloud KMS · Thu 2026-10-08

Terraform GCP Cloud KMS rotation_period, destroy_scheduled_duration, prevent_destroy, and a Rust census

Destroy removes the key from state. It does not remove it from the project. It does remove your ability to decrypt.

Lesson Class: Ops (Terraform + GCP Cloud KMS)
Topic: T2 Ops scripting
Cloud Referent: google_kms_crypto_key · rotation_period > 86400s · DESTROY_SCHEDULED 30-day default · no auto-rotation for asymmetric keys
Automation: cargo script · serde_json over gcloud kms keys list JSON · Nix writeBashBin
Verified: -Zscript run over synthetic fixture (exit 2, 0 warnings) · terraform 1.16.5 validate -json valid · bad rotation_period rejected at plan · nix-build OK on the lab Mac · GCP output synthetic (no credentials)
Paired Dev: Rust TF integration tests over validate -json and plan -json
Paired Cert: Terraform Associate provider constraints and the lock file
The Tombstone Rule
Destroy on a KMS key removes it from state, keeps the name, and starts a clock on your ciphertext.
Validate the Seconds
Put the rotation policy in a variable validation so a bad period fails at plan.
The data belongs to the service. The ability to read it belongs to the key.

Destroy removes the key from state. It does not remove it from the project. It does remove your ability to decrypt.

§I. Frame

The last three TF Ops lessons were GCP Artifact Registry on 09-29, AWS CloudWatch Logs on 10-02, and Azure Storage on 10-05. Today returns to GCP with Cloud KMS, which no lesson on SoT owns.

A customer-managed encryption key (CMEK) sits under a Cloud SQL instance, a bucket, or a BigQuery dataset. The data belongs to the service. The ability to read it belongs to the key. Three Terraform arguments decide how that key ages and how it dies: rotation_period, destroy_scheduled_duration, and the prevent_destroy lifecycle flag.

The problem for today: declare a key that rotates on a schedule and cannot be destroyed by a stray plan, then census an existing key ring for keys that never rotate, rotate too slowly, or would vanish too fast.

§II. The key that destroy cannot delete

The provider page opens with a warning. CryptoKeys cannot be deleted from Google Cloud. Destroying a Terraform-managed key removes it from state and deletes all its versions, which renders the key unusable. The key name stays in the project. Any data encrypted under it becomes unrecoverable.

The Tombstone Rule (named technique). For a google_kms_crypto_key, terraform destroy writes a tombstone, not a cleanup. The name stays taken, the versions go to DESTROY_SCHEDULED, and every ciphertext waits on a clock.

That clock is destroy_scheduled_duration. If you omit it at creation time, versions spend 30 days in DESTROY_SCHEDULED before they become DESTROYED. During that window a version can be restored. Shorten it and you shorten the time anyone has to notice.

Brikman puts prevent_destroy = true on the S3 bucket that holds Terraform state (PDF pp.151-152): a plan that would destroy the resource fails instead. The provider doc recommends the same guard for keys, and its own example carries it:

resource "google_kms_crypto_key" "orders" {
  name                       = "orders-cmek"
  key_ring                   = google_kms_key_ring.app.id
  purpose                    = "ENCRYPT_DECRYPT"
  rotation_period            = var.rotation_period
  destroy_scheduled_duration = "2592000s"

  lifecycle {
    prevent_destroy = true
  }
}

prevent_destroy guards Terraform only. It does nothing about a console click or a gcloud kms keys versions destroy. IAM guards those.

§III. The period is a string

rotation_period takes seconds with an s suffix, such as "7776000s" for 90 days. The provider requires it to exceed one day (86400s). The first rotation happens one period after you set it, not at apply.

Rotation creates a new primary version. Two facts from the Cloud KMS rotation guide matter for the census:

  1. Rotation does not re-encrypt. Old data stays under old versions, so old versions must stay enabled until the data is rewrapped.
  2. Cloud KMS does not auto-rotate asymmetric keys. A key with purpose ASYMMETRIC_SIGN or ASYMMETRIC_DECRYPT needs a manual rotation and a public-key redistribution.

Validate the Seconds (named technique). The bundle's kms.tf puts the policy in a variable validation, so a bad value fails at plan before any provider call:

validation {
  condition     = can(regex("^[0-9]+s$", var.rotation_period)) && tonumber(trimsuffix(var.rotation_period, "s")) > 86400 && tonumber(trimsuffix(var.rotation_period, "s")) <= 7776000
  error_message = "rotation_period must look like 7776000s, exceed 86400s, and be at most 90 days (7776000s)."
}

The 90-day ceiling is house policy, not a provider rule. Checked on the box with terraform 1.16.5: init -backend=false resolved hashicorp/google 7.46.1, validate -json returned "valid": true with zero diagnostics, and plan -var rotation_period=3600s returned Error: Invalid value for variable next to the expected no-credentials provider errors.

§IV. The Rust census

The census is a cargo script that reads gcloud kms keys list --format=json from a file or stdin and deserializes each key with serde. rotationPeriod and destroyScheduledDuration arrive as strings like "7776000s", so one helper strips the suffix and parses seconds. Flags:

FlagRuleAttention
no_rotationENCRYPT_DECRYPT with no rotationPeriodyes
rotation_over_max=Ndperiod longer than --max-days (default 90)yes
asymmetric_manualpurpose starts with ASYMMETRIC_no, note only
short_destroy_window=NddestroyScheduledDuration under 30 daysyes
primary_not_enabled=Sprimary version state is not ENABLEDyes

The purpose match carries §III:

if purpose.starts_with("ASYMMETRIC_") {
    out.push("asymmetric_manual".to_string());
} else if purpose == "ENCRYPT_DECRYPT" {
    match k.rotation_period.as_deref() {
        None => { out.push("no_rotation".to_string()); attention = true; }
        Some(p) if secs(p)? > max_days * DAY => {
            out.push(format!("rotation_over_max={}d", secs(p)? / DAY));
            attention = true;
        }
        Some(_) => {}
    }
}

Real run, synthetic input. The box ran the script under cargo +nightly -Zscript against the bundle's fixture/keys.json. The fixture is invented in the documented CryptoKey shape (no GCP credentials this fire). The output below is the script's actual output over that fixture:

key	orders-cmek	ok	-
key	media-cmek	ATTENTION	no_rotation
key	logs-cmek	ATTENTION	rotation_over_max=365d,short_destroy_window=1d,primary_not_enabled=DISABLED
key	release-signer	ok	asymmetric_manual
summary keys=4 attention=2 max_days=90
exit 2

§V. How to run

gcloud kms keys list --location us-east1 --keyring app --format=json \
  | cargo +nightly -Zscript ./gcp-kms-rotation-census.rs --max-days 90

IAM for a live run is read-only: cloudkms.cryptoKeys.list on the key ring. The script never writes and exits 2 when any key needs attention. Nix wrapper: gcp-kms-rotation-census.nix (writeBashBin), built on the lab Mac this fire; the built binary printed the same four rows.

§VI. Close

The Tombstone Rule: destroy on a KMS key starts a 30-day clock on your ciphertext, so put prevent_destroy on every key Terraform owns. Validate the Seconds: make a bad rotation_period fail at plan. Run the census against one real key ring and write down which keys you expect to flag before you read the output.

Paired Dev drives terraform validate -json and plan -json from Rust integration tests and asserts the rotation variable rejects 3600s. Paired Cert reads the lock file that pinned hashicorp/google 7.46.1 above.

Related