From e15073dd5eb18ca94877530cac97dfb6399c4935 Mon Sep 17 00:00:00 2001 From: Thomas Sapelza Date: Mon, 17 Aug 2026 11:16:37 +0200 Subject: [PATCH] add a Terraform pitfalls section to the docs (fixes #58) --- README.md | 4 ++ docs/terraform.md | 103 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 docs/terraform.md diff --git a/README.md b/README.md index 29303ea..750e5d5 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,10 @@ Further documentation of each Custom Resource can be found here: - [Grant](docs/grant.md) - Manage privileges. - [DefaultPrivilege](docs/default-privilege.md) - Manage default privileges. +If you manage the Custom Resources with Terraform, please also read: + +- [Terraform](docs/terraform.md) - Pitfalls when using the `kubernetes_manifest` resource. + ### Declarative Management The Operator leverages the power of Kubernetes Custom Resource Definitions (CRDs) to manage PostgreSQL resources declaratively. diff --git a/docs/terraform.md b/docs/terraform.md new file mode 100644 index 0000000..6bc4817 --- /dev/null +++ b/docs/terraform.md @@ -0,0 +1,103 @@ +# Terraform + +The Custom Resources of this Operator can be managed with the +[`kubernetes_manifest`](https://registry.terraform.io/providers/hashicorp/kubernetes/latest/docs/resources/manifest) +resource of the `hashicorp/kubernetes` provider. + +## Never set optional fields to `null` + +**Omit** optional spec fields from the manifest instead of setting them to `null`. A field that is present but `null` produces a permanent in-place update on every single plan: + +```hcl +# module.postgresql_role.kubernetes_manifest.postgresql_role_cr will be updated in-place +~ resource "kubernetes_manifest" "postgresql_role_cr" { + ~ object = { + ~ spec = { + + comment = (known after apply) + ~ flags = { + + validUntil = (known after apply) + } + } + } + } +``` + +### Why this happens + +While planning, the provider fills every field with the CRD schema that the configuration does not set with an unknown value, +and then takes the value from the prior state again - unless the field was present in the previous configuration. +In that case it keeps the value unknown, to give the API server a chance to default it. + +A `null` counts as "present" here. As this Operator does not default these fields, the applied object never contains them, the refreshed state holds `null` again, +and the next plan repeats the same `(known after apply)`. The plan never converges. + +A field that is **absent** from the configuration produces no diff at all. + +This is a known limitation of the provider, see [hashicorp/terraform-provider-kubernetes#2669](https://github.com/hashicorp/terraform-provider-kubernetes/issues/2669). + +### How to avoid it + +Build the `spec` with [`merge`](https://developer.hashicorp.com/terraform/language/functions/merge) +and only add optional attributes when they actually have a value: + +```hcl +variable "comment" { + type = string + default = null + nullable = true +} + +variable "valid_until" { + type = string + default = null + nullable = true +} + +resource "kubernetes_manifest" "postgresql_role_cr" { + manifest = { + apiVersion = "postgresql.aboutbits.it/v1" + kind = "Role" + + metadata = { + namespace = var.namespace + name = var.name + } + + spec = merge( + { + clusterRef = { + namespace = var.cluster_ref_namespace + name = var.cluster_ref_name + } + name = var.role + flags = merge( + { + createdb = var.flag_createdb + }, + var.valid_until == null ? {} : { + validUntil = var.valid_until + }, + ) + }, + var.comment == null ? {} : { + comment = var.comment + }, + ) + } +} +``` + +For variables that have a non-`null` default, declaring them as `nullable = false` additionally makes Terraform fall back to the default whenever a caller passes `null` explicitly. + +### Affected fields + +Every optional field of every Custom Resource is affected, in particular: + +| Custom Resource | Optional fields | +|---------------------|------------------------------------------------------------------------------------------------| +| `ClusterConnection` | `parameters`, `adminSecretRef.namespace` | +| `Database` | `owner`, `reclaimPolicy`, `clusterRef.namespace` | +| `Schema` | `owner`, `reclaimPolicy`, `clusterRef.namespace` | +| `Role` | `comment`, `passwordSecretRef`, `flags` (including `flags.validUntil`), `clusterRef.namespace` | +| `Grant` | `schema`, `objects`, `clusterRef.namespace` | +| `DefaultPrivilege` | `schema`, `clusterRef.namespace` |