From 2d7b601e3179f4b72a884b4547e734bceb7405f2 Mon Sep 17 00:00:00 2001 From: Patrick Hermann Date: Sat, 29 Aug 2026 14:46:25 +0000 Subject: [PATCH] feat(cloudnative-pg): add CloudNativePG operator + Cluster catalog entry Adds infra/cloudnative-pg with two independent sub-entries, both sourcing https://cloudnative-pg.github.io/charts: - install/ cnpg/cloudnative-pg 0.29.0 (app 1.30.0), sync-wave -10 - cluster/ cnpg/cluster 0.8.1 (the official Cluster CR chart), sync-wave 0 Split because the lifecycles differ: one operator per cluster, N databases against it. cluster/ derives its Application name from clusterName + destination so several Postgres clusters can share a target cluster. Three catalog entries (backstage, zitadel, harbor) each carry a bundled Bitnami Postgres, one of them pinned to bitnamilegacy. This entry is the shared managed alternative; migrating those three is deliberately not part of this change. Notes: - destination.namespace defaults to `postgres` for install/, matching stuttgart-things/helm's database/postgres.yaml.gotmpl rather than upstream's cnpg-system. Documented in the README so nobody "fixes" it. - ServerSideApply is required, not stylistic: the clusters.postgresql.cnpg.io CRD exceeds the 262144-byte last-applied-configuration annotation limit. - cluster/ emits cluster.initdb.secret only when appSecretName is non-empty; an empty secret.name breaks CNPG bootstrap. - owner is schema-constrained to exclude `{}` because upstream runs cluster.initdb.owner through tpl. Root README gains the infra/ row only; the stale entry counts in that index are left alone. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018THyZwQsAgdoZSC4ZY4eGf --- README.md | 1 + infra/cloudnative-pg/README.md | 247 ++++++++++++++++++ infra/cloudnative-pg/cluster/Chart.yaml | 7 + .../cluster/templates/chart.yaml | 84 ++++++ .../cloudnative-pg/cluster/values.schema.json | 112 ++++++++ infra/cloudnative-pg/cluster/values.yaml | 119 +++++++++ infra/cloudnative-pg/install/Chart.yaml | 7 + .../install/templates/chart.yaml | 43 +++ .../cloudnative-pg/install/values.schema.json | 65 +++++ infra/cloudnative-pg/install/values.yaml | 57 ++++ 10 files changed, 742 insertions(+) create mode 100644 infra/cloudnative-pg/README.md create mode 100644 infra/cloudnative-pg/cluster/Chart.yaml create mode 100644 infra/cloudnative-pg/cluster/templates/chart.yaml create mode 100644 infra/cloudnative-pg/cluster/values.schema.json create mode 100644 infra/cloudnative-pg/cluster/values.yaml create mode 100644 infra/cloudnative-pg/install/Chart.yaml create mode 100644 infra/cloudnative-pg/install/templates/chart.yaml create mode 100644 infra/cloudnative-pg/install/values.schema.json create mode 100644 infra/cloudnative-pg/install/values.yaml diff --git a/README.md b/README.md index d4bc2601..f8cd0d2e 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Version columns show what the child `Application` currently pins. `—` in the V |---|---|---|---| | [`cert-manager`](./infra/cert-manager/) | `install` / `selfsigned` / `cluster-ca` / `vault-pki` | `v1.19.2` + — + — + — | cert-manager chart, self-signed `ClusterIssuer`, CA chain (`cluster-ca` Certificate + ClusterIssuer + one or two wildcards), Vault PKI `ClusterIssuer` (token auth) | | [`cilium`](./infra/cilium/) | `chart` / `lb` / `gateway` | `1.18.5` + — + — | CNI with kube-proxy replacement, L2 LoadBalancer IP pool, Gateway API `Gateway` | +| [`cloudnative-pg`](./infra/cloudnative-pg/) | `install` / `cluster` | `0.29.0` + `0.8.1` | CloudNativePG operator + a PostgreSQL `Cluster` CR (official `cnpg/cluster` chart) | | [`external-secrets`](./infra/external-secrets/) | `install` / `cluster-secret-store-vault` | `2.4.1` + — | External Secrets Operator (ESO) + a templated `ClusterSecretStore` for Vault (k8s-auth, KV v2). Cluster overlays consume `cluster-secret-store-vault` per `(cluster, KV path)` pair | | [`kyverno`](./infra/kyverno/) | `install` | `3.8.0` | Kyverno admission controller (policy engine) — `ClusterPolicy` / `Policy` / `PolicyException` CRDs. Controller only; policies are cluster-specific | | [`longhorn`](./infra/longhorn/) | `install` | `1.11.2` | Longhorn distributed block storage; GitOps-friendly defaults (`preUpgradeChecker.jobEnabled: false`, `defaultClassReplicaCount: 1` for single-node-safe install) | diff --git a/infra/cloudnative-pg/README.md b/infra/cloudnative-pg/README.md new file mode 100644 index 00000000..60148f4d --- /dev/null +++ b/infra/cloudnative-pg/README.md @@ -0,0 +1,247 @@ +# infra/cloudnative-pg + +Catalog entry for [**CloudNativePG**](https://cloudnative-pg.io/) — the PostgreSQL operator — plus the official [`cnpg/cluster`](https://github.com/cloudnative-pg/charts/tree/main/charts/cluster) chart that renders a `postgresql.cnpg.io/v1` `Cluster`. Both sub-entries are **app-of-apps Helm charts**: consumers create one ArgoCD `Application` pointing at a sub-entry, and it renders the child `Application` that installs the upstream chart from . + +Port of the Helmfile definitions in [`stuttgart-things/helm`](https://github.com/stuttgart-things/helm) (`database/postgres.yaml.gotmpl`, `database/postgres-cluster.yaml.gotmpl`, merged in [helm#161](https://github.com/stuttgart-things/helm/pull/161)). The `environments.default.values` blocks there map to first-class values here. + +## Why this exists + +Three catalog entries carry their own bundled PostgreSQL, and all three are on a path that is closing: + +| Entry | How it gets Postgres today | +|---|---| +| [`apps/backstage/install`](../../apps/backstage/install/) | bundled Bitnami StatefulSet (`postgresql.enabled`), password via the `backstage-secrets` Secret | +| [`apps/zitadel/install`](../../apps/zitadel/install/) | bundled Bitnami subchart of the upstream ZITADEL chart, credentials via ESO from Vault | +| [`apps/harbor/install`](../../apps/harbor/install/) | bundled, image pinned to `bitnamilegacy/postgresql` | + +The `bitnamilegacy` pin is a deprecation shim for the Bitnami wind-down, and `apps/zitadel/install/values.yaml` already tells you in its own comment to point ZITADEL at managed PostgreSQL for production. The catalog has been routing around the absence of a managed Postgres three times; this entry is the shared answer. + +**Migrating those three is deliberately out of scope.** Moving them off their bundled StatefulSets means data movement and a per-app credentials rewire — its own change, not a rider on this one. This entry is what makes it possible later. + +`infra/` rather than `apps/` because CNPG is a *dependency* of other catalog entries, which puts it alongside `external-secrets` and `cert-manager`. [`apps/redis-stack`](../../apps/redis-stack/) is a surface-level analog (stateful data service, persistence, a password) but it is a leaf — nothing in the catalog consumes it. + +## Layout + +``` +infra/cloudnative-pg/ +├── install/ the operator — one per cluster +│ ├── Chart.yaml +│ ├── values.yaml +│ ├── values.schema.json +│ └── templates/ +│ └── chart.yaml renders Application "cloudnative-pg-" (sync-wave -10) +└── cluster/ one PostgreSQL Cluster CR — N per cluster + ├── Chart.yaml + ├── values.yaml + ├── values.schema.json + └── templates/ + └── chart.yaml renders Application "cnpg-cluster-" (sync-wave 0) +``` + +Split because the lifecycles genuinely differ: **one operator per cluster, N databases against it**. A consumer already running the operator can take `cluster/` alone, and `cluster/` is instantiable more than once per cluster (the derived Application name hashes `clusterName` *and* the destination, so two databases on one cluster don't collide). + +## What gets deployed + +### `install/` — the operator + +One child `Application` pointing at `https://cloudnative-pg.github.io/charts`, chart `cloudnative-pg` at `.Values.chartVersion` (**0.29.0**, app **1.30.0**). Installs the operator Deployment, its webhooks, RBAC, and **11 CRDs** (`clusters`, `poolers`, `backups`, `scheduledbackups`, `databases`, `publications`, `subscriptions`, `imagecatalogs`, `clusterimagecatalogs`, `databaseroles`, `failoverquorums` — all `.postgresql.cnpg.io`). + +The computed `valuesObject` is deliberately thin — `replicaCount`, `monitoring.podMonitorEnabled`, `resources`. Everything else goes through `extraValues`. + +**Namespace deviation:** `destination.namespace` defaults to **`postgres`**, matching `stuttgart-things/helm`'s `database/postgres.yaml.gotmpl`. Upstream's own convention is `cnpg-system`. Consistency across the two sibling repos won here, and it is a one-value override either way — don't "fix" it. + +### `cluster/` — the `Cluster` CR + +One child `Application` pointing at the same repo, chart `cluster` at `.Values.chartVersion` (**0.8.1**). This is the *official* CNPG chart for the CR — the catalog does not hand-roll a `postgresql.cnpg.io/v1` `Cluster` template. + +The computed `valuesObject` maps catalog values onto the upstream keys: + +| Catalog value | Upstream key | +|---|---| +| `clusterName` | `nameOverride` + `fullnameOverride` (and the child Application's `releaseName`) | +| `type` / `mode` | `type` / `mode` | +| `postgresqlVersion` | `version.postgresql` | +| `instances` | `cluster.instances` | +| `storage.{size,storageClass}` | `cluster.storage.*` | +| `walStorage.{enabled,size}` | `cluster.walStorage.*` | +| `database` / `owner` | `cluster.initdb.database` / `cluster.initdb.owner` | +| `appSecretName` | `cluster.initdb.secret.name` — **emitted only when non-empty** | +| `enableSuperuserAccess` / `enablePDB` | `cluster.enableSuperuserAccess` / `cluster.enablePDB` | +| `monitoring.{enabled,podMonitor,prometheusRule}` | `cluster.monitoring.{enabled,podMonitor.enabled,prometheusRule.enabled}` | +| `backups.enabled` | `backups.enabled` | +| `extraValues` | deep-merged over all of the above | + +Defaults are **lab-shaped**, matching the Helmfile. Two things to read before using them in anger: + +- **`instances: 1` means an instance failure is downtime and possible data loss.** There is no replica to fail over to. Raise to `3` for anything you care about (and turn on `enablePDB` with it). +- **`backups.enabled: false`**, so the upstream chart prints `Warning! Backups not enabled. Recovery will not be possible!` on every sync. It is telling the truth. Enabling backups needs an object-store target — configure the rest of `backups.*` through `extraValues`. + +Leaving `appSecretName` empty is the normal path: CNPG then generates `-app`, a `kubernetes.io/basic-auth` Secret carrying `username` / `password` / `dbname`. An *empty* `secret.name` reaching the upstream chart breaks bootstrap, which is why the template omits the key entirely rather than passing `""`. + +One template-level trap worth knowing: upstream runs `cluster.initdb.owner` through `tpl`, so a literal `{{ … }}` in that value gets evaluated by the upstream chart. The schema forbids braces in `owner` to stop a computed value leaking them in. + +## Sync waves and ordering + +`install/` carries **`sync-wave: "-10"`** — matching cert-manager, crossplane, external-secrets and kyverno — because the CRDs must register before any `Cluster` CR is applied. `cluster/` carries **`"0"`**. + +**Sync waves only order resources within one Argo `Application`.** A consumer taking both sub-entries in one aggregator gets the ordering for free. A consumer syncing `cluster/` against a cluster with no operator installed sees the child app fail its dry-run on the unknown `postgresql.cnpg.io/v1` kind — that is expected, and the `retry` block (limit 5, exponential backoff to 3m) is what recovers it once the operator lands. Keep the retry block; it is load-bearing, not boilerplate. + +`syncOptions` for both: `CreateNamespace=true`, `ServerSideApply=true`. **`ServerSideApply` is not optional here** — the `clusters.postgresql.cnpg.io` CRD is far past the 262144-byte limit for the `kubectl.kubernetes.io/last-applied-configuration` annotation, so a client-side apply fails outright. If SSA still trips on CRD updates across a major operator bump, add `Replace=true` the way [`cicd/kro/install`](../../cicd/kro/) does. + +## Consumer usage + +Operator once per cluster: + +```yaml +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: cloudnative-pg + namespace: argocd +spec: + project: my-cluster + source: + repoURL: https://github.com/stuttgart-things/argocd.git + targetRevision: main + path: infra/cloudnative-pg/install + helm: + values: | + project: my-cluster + destination: + server: https://:6443 + namespace: postgres + destination: + server: https://kubernetes.default.svc + namespace: argocd + syncPolicy: + automated: { prune: true, selfHeal: true } + syncOptions: [CreateNamespace=true, ServerSideApply=true] +``` + +Then one `Application` per database, **in the consuming app's namespace**: + +```yaml +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: backstage-db + namespace: argocd +spec: + project: my-cluster + source: + repoURL: https://github.com/stuttgart-things/argocd.git + targetRevision: main + path: infra/cloudnative-pg/cluster + helm: + values: | + project: my-cluster + destination: + server: https://:6443 + namespace: backstage # NOT the operator's namespace + clusterName: backstage-db + instances: 3 + enablePDB: true + storage: + size: 20Gi + storageClass: openebs-hostpath + database: backstage + owner: backstage + appSecretName: backstage-db-app # materialised by ESO — see below + destination: + server: https://kubernetes.default.svc + namespace: argocd + syncPolicy: + automated: { prune: true, selfHeal: true } + syncOptions: [CreateNamespace=true, ServerSideApply=true] +``` + +The `Cluster` exposes three Services in its namespace: `-rw` (primary), `-ro` (replicas), `-r` (any instance). + +## Values reference + +### `install/` + +| Key | Default | Purpose | +|---|---|---| +| `project` | `default` | AppProject for the rendered Application | +| `applicationName` | derived | Explicit child Application name; defaults to `cloudnative-pg-` | +| `destination.server` / `.name` | `https://kubernetes.default.svc` / — | Target cluster (URL or registered name; `name` wins when both are set) | +| `destination.namespace` | `postgres` | Operator namespace — deviates from upstream's `cnpg-system` on purpose | +| `chartVersion` | `0.29.0` | Upstream `cnpg/cloudnative-pg` chart version | +| `replicaCount` | `1` | Operator Deployment replicas (leader-elected — >1 buys failover, not throughput) | +| `monitoring.podMonitorEnabled` | `false` | PodMonitor for the *operator* pod; needs Prometheus Operator CRDs | +| `resources` | `{}` | Requests/limits for the operator container | +| `extraValues` | `{}` | Deep-merged on top of the computed upstream `valuesObject` | +| `syncPolicy` | automated + retry | Applied to the rendered Application | + +### `cluster/` + +| Key | Default | Purpose | +|---|---|---| +| `project` | `default` | AppProject for the rendered Application | +| `applicationName` | derived | Explicit child Application name; defaults to `cnpg-cluster-` | +| `destination.server` / `.name` | `https://kubernetes.default.svc` / — | Target cluster (URL or registered name) | +| `destination.namespace` | `cnpg-cluster` (**placeholder — override**) | Namespace for the `Cluster`. See the warning below | +| `chartVersion` | `0.8.1` | Upstream `cnpg/cluster` chart version | +| `clusterName` | `postgres-cluster` | Name of the `Cluster` CR; also the child Helm `releaseName` | +| `type` | `postgresql` | `postgresql` \| `postgis` \| `timescaledb` | +| `mode` | `standalone` | `standalone` \| `recovery` \| `replica` (the latter two need `extraValues`) | +| `postgresqlVersion` | `"17"` | Major PostgreSQL version (string) | +| `instances` | `1` | Instance count — **1 has no failover** | +| `storage.size` / `.storageClass` | `8Gi` / `""` | PGDATA volume; empty class = cluster default SC | +| `walStorage.enabled` / `.size` | `false` / `1Gi` | Separate WAL volume | +| `database` / `owner` | `app` / `app` | initdb database and owner role (`owner` may not contain `{}`) | +| `appSecretName` | `""` | Existing `kubernetes.io/basic-auth` Secret; empty = CNPG generates `-app` | +| `enableSuperuserAccess` | `false` | Off = the `postgres` password is blanked; the owner role is the only way in | +| `enablePDB` | `false` | Off because a PDB blocks node drains on a single-instance cluster | +| `monitoring.enabled` / `.podMonitor` / `.prometheusRule` | `false` | Prometheus Operator integration for the PostgreSQL pods | +| `backups.enabled` | `false` | See the warning above — no backups means no recovery | +| `extraValues` | `{}` | Deep-merged on top of the computed upstream `valuesObject` | +| `syncPolicy` | automated + retry | Applied to the rendered Application | + +> **`destination.namespace` is the value you are most likely to get wrong.** A CNPG `Cluster` belongs in the namespace of the app that consumes it — backstage's cluster in `backstage`, zitadel's in `zitadel` — not in the operator's namespace. There is no good default, so the schema marks it `required` and `values.yaml` carries an inert placeholder (`cnpg-cluster`) purely so the chart renders standalone. Because Helm deep-merges maps, a consumer who overrides only `destination.server` silently inherits that placeholder. **Set it explicitly.** + +## Secret management + +Same three options as [`apps/redis-stack`](../../apps/redis-stack/README.md#secret-management) — ArgoCD Vault Plugin, SOPS + AVP, or ApplicationSet `valuesObject` templating. Since the catalog already ships [`infra/external-secrets/cluster-secret-store-vault`](../external-secrets/) and [`apps/zitadel/external-secrets`](../../apps/zitadel/) is a working precedent for exactly this shape, the cleanest story is: + +1. ESO materialises a `kubernetes.io/basic-auth` Secret in the app's namespace, with `username` and `password` keys. +2. `appSecretName` points at it. +3. CNPG uses it as the owner's credentials at bootstrap instead of generating its own. + +If you skip all of that and leave `appSecretName` empty, CNPG generates `-app` and the consuming app reads the password from there — fine for a lab, but the credential then lives only in the cluster. + +**Never commit a literal password.** + +## Teardown caveat + +Deleting the Argo `Application` removes the Helm release but **not** the PVC, the namespace, or the CNPG CRDs. Verified during testing. A full teardown is: + +```bash +kubectl -n delete pvc -1 +kubectl delete crd clusters.postgresql.cnpg.io poolers.postgresql.cnpg.io \ + backups.postgresql.cnpg.io scheduledbackups.postgresql.cnpg.io \ + databases.postgresql.cnpg.io publications.postgresql.cnpg.io \ + subscriptions.postgresql.cnpg.io imagecatalogs.postgresql.cnpg.io \ + clusterimagecatalogs.postgresql.cnpg.io databaseroles.postgresql.cnpg.io \ + failoverquorums.postgresql.cnpg.io +``` + +PVC-survives-deletion is a **feature for a database, not a bug** — it is what keeps a fat-fingered `Application` delete from destroying data. The corollary is the sharp edge: `prune: true` will not save you from an accidental `clusterName` rename. A rename creates a *new* `Cluster` with a *new* empty PVC and orphans the old one, still holding your data, still costing storage. + +## Verified upstream behaviour + +Both charts were exercised on a live RKE2 cluster (v1.35.3, `openebs-hostpath` as default SC): + +- fresh install reaches `Cluster in healthy state`, PostgreSQL 17.11, `app` database and `app` owner created, `-rw` / `-ro` / `-r` Services present; +- the operator upgrades **in place** 1.26.0 → 1.30.0 with a running cluster, and the cluster returns to healthy on its own. + +## Known tension + +`cluster/` strains the catalog's stated contract. The [root README](../../README.md) says the catalog holds WHAT and clusters bring WHERE and HOW — and a `Cluster` CR carrying `database` / `owner` / `appSecretName` is closer to HOW. [`apps/redis-stack`](../../apps/redis-stack/) already bends the same way (password, persistence and sentinel topology all live in the catalog entry), so there is precedent. It is still a bend, and it is named here rather than discovered later. + +## Related + +- Upstream charts: (`cloudnative-pg`, `cluster`) +- CloudNativePG docs: +- Helmfile equivalents: [`stuttgart-things/helm`](https://github.com/stuttgart-things/helm) — `database/postgres.yaml.gotmpl`, `database/postgres-cluster.yaml.gotmpl` diff --git a/infra/cloudnative-pg/cluster/Chart.yaml b/infra/cloudnative-pg/cluster/Chart.yaml new file mode 100644 index 00000000..9f624ddd --- /dev/null +++ b/infra/cloudnative-pg/cluster/Chart.yaml @@ -0,0 +1,7 @@ +--- +apiVersion: v2 +name: cloudnative-pg-cluster +description: App-of-apps chart packaging the official CloudNativePG cluster Helm chart (cnpg/cluster) — renders one postgresql.cnpg.io/v1 Cluster +type: application +version: 0.1.0 +appVersion: "0.8.1" diff --git a/infra/cloudnative-pg/cluster/templates/chart.yaml b/infra/cloudnative-pg/cluster/templates/chart.yaml new file mode 100644 index 00000000..4858c3a2 --- /dev/null +++ b/infra/cloudnative-pg/cluster/templates/chart.yaml @@ -0,0 +1,84 @@ +{{- $destKey := .Values.destination.server | default .Values.destination.name }} +{{/* +Derive from clusterName + destination, not destination alone: several Postgres +clusters can land on one target cluster and must not collide. +*/}} +{{- $defaultName := printf "cnpg-cluster-%s" (sha1sum (printf "%s@%s" .Values.clusterName $destKey) | trunc 8) }} +{{- $appName := .Values.applicationName | default $defaultName }} +{{/* +initdb — emit `secret` ONLY when appSecretName is non-empty. Otherwise CNPG +generates `-app` (a kubernetes.io/basic-auth Secret carrying +username/password/dbname), and an empty `secret.name` breaks bootstrap. + +Note: upstream runs `cluster.initdb.owner` through `tpl`, so a literal +`{{ ... }}` reaching that value is evaluated by the upstream chart. The schema +forbids braces in `owner` to keep that from happening. +*/}} +{{- $initdb := dict + "database" .Values.database + "owner" .Values.owner +}} +{{- if .Values.appSecretName }} + {{- $_ := set $initdb "secret" (dict "name" .Values.appSecretName) }} +{{- end }} +{{- $clusterBlock := dict + "instances" (int .Values.instances) + "storage" (dict + "size" .Values.storage.size + "storageClass" .Values.storage.storageClass + ) + "walStorage" (dict + "enabled" .Values.walStorage.enabled + "size" .Values.walStorage.size + ) + "initdb" $initdb + "enableSuperuserAccess" .Values.enableSuperuserAccess + "enablePDB" .Values.enablePDB + "monitoring" (dict + "enabled" .Values.monitoring.enabled + "podMonitor" (dict "enabled" .Values.monitoring.podMonitor) + "prometheusRule" (dict "enabled" .Values.monitoring.prometheusRule) + ) +}} +{{- $computed := dict + "nameOverride" .Values.clusterName + "fullnameOverride" .Values.clusterName + "type" .Values.type + "mode" .Values.mode + "version" (dict "postgresql" .Values.postgresqlVersion) + "cluster" $clusterBlock + "backups" (dict "enabled" .Values.backups.enabled) +}} +{{- $extra := .Values.extraValues | default dict }} +{{- $merged := mergeOverwrite $computed (deepCopy $extra) }} +--- +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: {{ $appName | quote }} + namespace: argocd + annotations: + # 0 — after the operator's -10 wave, so the CRDs exist before the Cluster CR. + # Sync waves only order resources within ONE Application: syncing this entry + # against a cluster with no operator fails the dry-run on the unknown + # postgresql.cnpg.io/v1 kind, and syncPolicy.retry is what recovers it. + argocd.argoproj.io/sync-wave: "0" +spec: + project: {{ .Values.project | quote }} + source: + repoURL: https://cloudnative-pg.github.io/charts + chart: cluster + targetRevision: {{ .Values.chartVersion | quote }} + helm: + releaseName: {{ .Values.clusterName | quote }} + valuesObject: + {{- toYaml $merged | nindent 8 }} + destination: + {{- if .Values.destination.name }} + name: {{ .Values.destination.name | quote }} + {{- else }} + server: {{ .Values.destination.server | quote }} + {{- end }} + namespace: {{ .Values.destination.namespace | quote }} + syncPolicy: + {{- toYaml .Values.syncPolicy | nindent 4 }} diff --git a/infra/cloudnative-pg/cluster/values.schema.json b/infra/cloudnative-pg/cluster/values.schema.json new file mode 100644 index 00000000..8fcef716 --- /dev/null +++ b/infra/cloudnative-pg/cluster/values.schema.json @@ -0,0 +1,112 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "cloudnative-pg cluster app-of-apps chart", + "type": "object", + "additionalProperties": false, + "required": ["project", "destination", "chartVersion", "clusterName"], + "properties": { + "applicationName": { + "type": "string", + "minLength": 1, + "description": "Optional explicit name for the rendered ArgoCD Application. Defaults to a name derived from clusterName + destination, so several Postgres clusters can land on one target cluster without colliding." + }, + "project": { "type": "string", "minLength": 1 }, + "destination": { + "type": "object", + "additionalProperties": false, + "description": "Argo destination for the rendered Application. Provide `server` (cluster URL) or `name` (registered cluster name) — at least one required. Template prefers `name` when both are set. `namespace` is required and has no good default: a CNPG Cluster belongs in the namespace of the app that consumes it, not in the operator's namespace.", + "properties": { + "server": { "type": "string", "format": "uri" }, + "name": { "type": "string", "minLength": 1 }, + "namespace": { "type": "string", "minLength": 1 } + }, + "required": ["namespace"], + "anyOf": [ + { "required": ["server"] }, + { "required": ["name"] } + ] + }, + "chartVersion": { "type": "string", "minLength": 1 }, + "clusterName": { + "type": "string", + "minLength": 1, + "maxLength": 50, + "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", + "description": "Name of the Cluster CR (upstream nameOverride + fullnameOverride). Renaming creates a new Cluster and orphans the old PVC." + }, + "type": { + "type": "string", + "enum": ["postgresql", "postgis", "timescaledb"] + }, + "mode": { + "type": "string", + "enum": ["standalone", "recovery", "replica"] + }, + "postgresqlVersion": { + "type": "string", + "pattern": "^[0-9]+(\\.[0-9]+)?$", + "description": "Major PostgreSQL version, as a string." + }, + "instances": { "type": "integer", "minimum": 1 }, + "storage": { + "type": "object", + "additionalProperties": false, + "properties": { + "size": { "type": "string", "minLength": 1, "pattern": "^[0-9]+(Ki|Mi|Gi|Ti|Pi|Ei)?$" }, + "storageClass": { "type": "string", "description": "Empty string = the target cluster's default StorageClass." } + }, + "required": ["size"] + }, + "walStorage": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "size": { "type": "string", "minLength": 1, "pattern": "^[0-9]+(Ki|Mi|Gi|Ti|Pi|Ei)?$" } + }, + "required": ["enabled"] + }, + "database": { "type": "string", "minLength": 1 }, + "owner": { + "type": "string", + "minLength": 1, + "pattern": "^[^{}]*$", + "description": "Owner role for the application database. Upstream runs this value through `tpl`, so Go-template braces are forbidden here." + }, + "appSecretName": { + "type": "string", + "description": "Existing kubernetes.io/basic-auth Secret with the owner's credentials. Empty = CNPG generates `-app`. Emitted to the upstream chart only when non-empty." + }, + "enableSuperuserAccess": { "type": "boolean" }, + "enablePDB": { "type": "boolean" }, + "monitoring": { + "type": "object", + "additionalProperties": false, + "description": "Prometheus Operator integration for the PostgreSQL pods. `podMonitor` and `prometheusRule` are gated by `enabled`.", + "properties": { + "enabled": { "type": "boolean" }, + "podMonitor": { "type": "boolean" }, + "prometheusRule": { "type": "boolean" } + }, + "required": ["enabled"] + }, + "backups": { + "type": "object", + "additionalProperties": false, + "description": "Backups are off by default; the upstream chart warns that recovery will not be possible. Configure the object-store target through extraValues.", + "properties": { + "enabled": { "type": "boolean" } + }, + "required": ["enabled"] + }, + "extraValues": { + "type": "object", + "description": "Deep-merged on top of the computed valuesObject passed to the upstream chart.", + "additionalProperties": true + }, + "syncPolicy": { + "type": "object", + "additionalProperties": true + } + } +} diff --git a/infra/cloudnative-pg/cluster/values.yaml b/infra/cloudnative-pg/cluster/values.yaml new file mode 100644 index 00000000..0d3fb27f --- /dev/null +++ b/infra/cloudnative-pg/cluster/values.yaml @@ -0,0 +1,119 @@ +--- +# ArgoCD AppProject the rendered Application belongs to. +project: default + +# Where the rendered Application deploys. +# +# There is NO good default for `namespace` — it is `required` in the schema and +# is the value a consumer is most likely to get wrong. A CNPG Cluster belongs in +# the namespace of the app that consumes it (backstage's cluster in `backstage`, +# zitadel's in `zitadel`), NOT in the operator's namespace (`postgres`). The +# value below is a placeholder so the chart renders standalone — override it. +destination: + server: https://kubernetes.default.svc + namespace: cnpg-cluster + +# Official CloudNativePG cluster Helm chart (https://cloudnative-pg.github.io/charts). +# renovate: datasource=helm depName=cluster registryUrl=https://cloudnative-pg.github.io/charts +chartVersion: 0.8.1 + +# Name of the Cluster CR (upstream nameOverride + fullnameOverride). Also seeds +# the derived Application name, so two Clusters on one target cluster don't +# collide. Renaming this creates a NEW Cluster and orphans the old PVC — +# see the teardown caveat in the README. +clusterName: postgres-cluster + +# Cluster flavour: postgresql | postgis | timescaledb. +type: postgresql + +# Bootstrap mode: standalone | recovery | replica. +# `recovery` and `replica` need their own upstream config — use extraValues. +mode: standalone + +# Major PostgreSQL version. Quoted — upstream expects a string. +postgresqlVersion: "17" + +# Number of PostgreSQL instances. +# +# 1 is lab-shaped, matching stuttgart-things/helm's database/postgres-cluster.yaml.gotmpl. +# At 1 an instance failure is downtime and possible data loss — there is no +# replica to fail over to. Raise to 3 for anything you care about. +instances: 1 + +# PGDATA volume. Empty storageClass = the cluster's default StorageClass. +storage: + size: 8Gi + storageClass: "" + +# Separate volume for the write-ahead log. Off by default; enable to keep WAL +# I/O off the data volume on busy clusters. +walStorage: + enabled: false + size: 1Gi + +# initdb bootstrap: the application database and its owner role. +database: app +owner: app + +# Name of an existing Secret holding the owner's credentials — must be a +# kubernetes.io/basic-auth Secret with `username` / `password` (and `dbname`). +# +# Leave EMPTY to let CNPG generate `-app` itself. An empty +# `secret.name` reaching the upstream chart breaks bootstrap, so the template +# emits `cluster.initdb.secret` only when this is non-empty. +# +# Point it at a Secret materialised by External Secrets — see the README. +appSecretName: "" + +# Superuser (`postgres`) access. Off by default: when disabled the operator +# blanks the postgres password, so the only way in is the owner role. +enableSuperuserAccess: false + +# PodDisruptionBudget. Off by default because it blocks node drains on a +# single-instance cluster. Turn on together with `instances: 3`. +enablePDB: false + +# Prometheus Operator integration for the PostgreSQL pods. All off by default; +# each requires the Prometheus Operator CRDs on the target cluster. +# `podMonitor` and `prometheusRule` are additionally gated by `enabled`. +monitoring: + enabled: false + podMonitor: false + prometheusRule: false + +# Backups. Off by default, which is why the upstream chart prints +# Warning! Backups not enabled. Recovery will not be possible! +# Enabling backups needs an object-store target — configure the rest of the +# upstream `backups.*` tree through extraValues. +backups: + enabled: false + +# Deep-merged on top of the computed valuesObject passed to the upstream chart. +# Use for any upstream key not exposed as a first-class value above +# (backups.barmanObjectStore, cluster.resources, cluster.affinity, +# cluster.postgresql.parameters, poolers, recovery.*, replica.*, …). +extraValues: {} + +# Sync policy applied to the rendered Application. +# +# ServerSideApply is NOT optional: the clusters.postgresql.cnpg.io CRD is far +# past the 262144-byte limit for the kubectl.kubernetes.io/last-applied- +# configuration annotation, so a client-side apply fails outright. +# +# The retry block is load-bearing, not boilerplate: sync waves only order +# resources WITHIN one Argo Application, so syncing this entry against a cluster +# whose operator isn't up yet fails the dry-run on the unknown +# postgresql.cnpg.io/v1 kind. The retry is what recovers it. Keep it. +syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true + - ServerSideApply=true + retry: + limit: 5 + backoff: + duration: 10s + factor: 2 + maxDuration: 3m diff --git a/infra/cloudnative-pg/install/Chart.yaml b/infra/cloudnative-pg/install/Chart.yaml new file mode 100644 index 00000000..ba7bfddf --- /dev/null +++ b/infra/cloudnative-pg/install/Chart.yaml @@ -0,0 +1,7 @@ +--- +apiVersion: v2 +name: cloudnative-pg-install +description: App-of-apps chart packaging the upstream CloudNativePG operator Helm chart (cnpg/cloudnative-pg) +type: application +version: 0.1.0 +appVersion: "1.30.0" diff --git a/infra/cloudnative-pg/install/templates/chart.yaml b/infra/cloudnative-pg/install/templates/chart.yaml new file mode 100644 index 00000000..e1d1f7ba --- /dev/null +++ b/infra/cloudnative-pg/install/templates/chart.yaml @@ -0,0 +1,43 @@ +{{- $destKey := .Values.destination.server | default .Values.destination.name }} +{{- $defaultName := printf "cloudnative-pg-%s" (sha1sum $destKey | trunc 8) }} +{{- $appName := .Values.applicationName | default $defaultName }} +{{/* +The operator chart needs almost nothing from us — only the knobs a cluster +actually varies get a first-class value. Everything else goes via extraValues. +*/}} +{{- $computed := dict + "replicaCount" (int .Values.replicaCount) + "monitoring" (dict "podMonitorEnabled" .Values.monitoring.podMonitorEnabled) + "resources" (.Values.resources | default dict) +}} +{{- $extra := .Values.extraValues | default dict }} +{{- $merged := mergeOverwrite $computed (deepCopy $extra) }} +--- +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: {{ $appName | quote }} + namespace: argocd + annotations: + # -10 alongside cert-manager / crossplane / external-secrets / kyverno: + # the postgresql.cnpg.io CRDs must register before any Cluster CR is applied. + argocd.argoproj.io/sync-wave: "-10" +spec: + project: {{ .Values.project | quote }} + source: + repoURL: https://cloudnative-pg.github.io/charts + chart: cloudnative-pg + targetRevision: {{ .Values.chartVersion | quote }} + helm: + releaseName: cloudnative-pg + valuesObject: + {{- toYaml $merged | nindent 8 }} + destination: + {{- if .Values.destination.name }} + name: {{ .Values.destination.name | quote }} + {{- else }} + server: {{ .Values.destination.server | quote }} + {{- end }} + namespace: {{ .Values.destination.namespace | quote }} + syncPolicy: + {{- toYaml .Values.syncPolicy | nindent 4 }} diff --git a/infra/cloudnative-pg/install/values.schema.json b/infra/cloudnative-pg/install/values.schema.json new file mode 100644 index 00000000..b3c8730f --- /dev/null +++ b/infra/cloudnative-pg/install/values.schema.json @@ -0,0 +1,65 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "cloudnative-pg install app-of-apps chart", + "type": "object", + "additionalProperties": false, + "required": ["project", "destination", "chartVersion"], + "properties": { + "applicationName": { + "type": "string", + "minLength": 1, + "description": "Optional explicit name for the rendered ArgoCD Application. Defaults to a per-cluster derived name to avoid collisions when deployed to multiple clusters." + }, + "project": { "type": "string", "minLength": 1 }, + "destination": { + "type": "object", + "additionalProperties": false, + "description": "Argo destination for the rendered Application. Provide `server` (cluster URL) or `name` (registered cluster name) — at least one required. Template prefers `name` when both are set (so consumers can override the chart's default `server` with a `name` without nulling). `namespace` defaults to `postgres`, not upstream's `cnpg-system`.", + "properties": { + "server": { "type": "string", "format": "uri" }, + "name": { "type": "string", "minLength": 1 }, + "namespace": { "type": "string", "minLength": 1 } + }, + "required": ["namespace"], + "anyOf": [ + { "required": ["server"] }, + { "required": ["name"] } + ] + }, + "chartVersion": { "type": "string", "minLength": 1 }, + "replicaCount": { "type": "integer", "minimum": 1 }, + "monitoring": { + "type": "object", + "additionalProperties": false, + "description": "Prometheus Operator integration for the operator pod itself. Requires the Prometheus Operator CRDs on the target cluster.", + "properties": { + "podMonitorEnabled": { "type": "boolean" } + }, + "required": ["podMonitorEnabled"] + }, + "resources": { + "type": "object", + "additionalProperties": false, + "description": "Resource requests/limits for the operator container. Empty object = upstream default (unset).", + "properties": { + "limits": { + "type": "object", + "additionalProperties": { "type": ["string", "integer"] } + }, + "requests": { + "type": "object", + "additionalProperties": { "type": ["string", "integer"] } + } + } + }, + "extraValues": { + "type": "object", + "description": "Deep-merged on top of the computed valuesObject passed to the upstream chart.", + "additionalProperties": true + }, + "syncPolicy": { + "type": "object", + "additionalProperties": true + } + } +} diff --git a/infra/cloudnative-pg/install/values.yaml b/infra/cloudnative-pg/install/values.yaml new file mode 100644 index 00000000..ef6ef572 --- /dev/null +++ b/infra/cloudnative-pg/install/values.yaml @@ -0,0 +1,57 @@ +--- +# ArgoCD AppProject the rendered Application belongs to. +project: default + +# Where the rendered Application deploys. +# +# `namespace` defaults to `postgres` to match stuttgart-things/helm's +# `database/postgres.yaml.gotmpl`. This is a deliberate deviation from +# upstream's own `cnpg-system` convention — consistency across the two sibling +# repos wins, and it is a one-value override either way. Don't "fix" it. +destination: + server: https://kubernetes.default.svc + namespace: postgres + +# Upstream CloudNativePG operator Helm chart (https://cloudnative-pg.github.io/charts). +# renovate: datasource=helm depName=cloudnative-pg registryUrl=https://cloudnative-pg.github.io/charts +chartVersion: 0.29.0 + +# Operator Deployment replica count. 1 is fine for most clusters — the operator +# leader-elects, so >1 buys failover, not throughput. +replicaCount: 1 + +# Prometheus Operator integration for the *operator* pod itself (not for the +# PostgreSQL clusters it manages — those are configured in `../cluster`). +# Requires the Prometheus Operator CRDs to be registered on the target cluster. +monitoring: + podMonitorEnabled: false + +# Resource requests/limits for the operator container. Empty = upstream default +# (unset), which lets the operator run in BestEffort. Set both on clusters with +# a ResourceQuota or a LimitRange that rejects unbounded pods. +resources: {} + +# Deep-merged on top of the computed valuesObject passed to the upstream chart. +# Use for any upstream key not exposed as a first-class value above +# (config.*, webhook.*, nodeSelector, tolerations, affinity, crds.create, +# additionalArgs/Env, image overrides, …). +extraValues: {} + +# Sync policy applied to the rendered Application. +# +# ServerSideApply is NOT optional: the clusters.postgresql.cnpg.io CRD is far +# past the 262144-byte limit for the kubectl.kubernetes.io/last-applied- +# configuration annotation, so a client-side apply fails outright. +syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true + - ServerSideApply=true + retry: + limit: 5 + backoff: + duration: 10s + factor: 2 + maxDuration: 3m